Skip to content

@tabula-css/eslint-plugin

Tabula's enforcement layer: the flat-config ESLint plugin that makes the closed vocabulary a hard gate. Reads the registry only — no Tailwind in its import graph.

Install

bash
npm install --save-dev @tabula-css/eslint-plugin

Dev dependency, alongside eslint (peer, >=9.0.0).

Overview

Where tabula build derives the closed vocabulary and tabula scan sweeps every source file for it in CI, @tabula-css/eslint-plugin is the editor- and pre-commit-facing gate: a class outside the registry, a class name assembled at runtime, or a stale exception all become lint errors with TAB-E/TAB-W diagnostic codes, live as you type. It reads the generated registry.json directly — it never imports Tailwind — so linting stays fast. Fifteen tabula/* rules ship, grouped into two presets (strict, migration).

Enable

eslint.config.js (flat config):

js
import tabula from '@tabula-css/eslint-plugin';

export default [
  {
    ...tabula.configs.strict,
    files: ['**/*.{ts,tsx}'],
    settings: {
      tabula: { registry: '.tabula/registry.json' },
    },
  },
];

strict turns every one of the 15 rules into an error. Adopting into an existing codebase? Use tabula.configs.migration instead: the four ban rules (no-runtime-class-construction, no-unregistered-arbitrary-value, no-theme-variant, no-important) stay hard errors, and the rest relax to warn, so you can land the switch incrementally.

settings.tabula

KeyDefaultMeaning
registrywalks up from cwd for .tabula/registry.jsonExplicit path to registry.json.
calleescn, cx, clsx, cva, tv, variants, twMerge, classnamesCall expressions whose arguments are treated as class sinks.
classAttributesclassName, classJSX attribute names treated as class sinks.
textLeafComponents[]Component names (<Card>) the project declares to render a text leaf, for inherited-property-boundary.
classMapSources/\.classmap(\.(c|m)?[jt]sx?)?$/Filename pattern for designated lookup-map modules (no-runtime-class-construction's A7 form).
classNameSources/^@tabula-css\/merge$/Module specifiers that may supply the branded ClassName type.
nowreal clockISO date override for exception-scope's expiry check, for deterministic tests.

Rules

tabula/registry-required

Requires a readable, schema-valid registry — the precondition every other registry-backed rule shares. Exposed standalone so a project can require it even with content rules off.

ts
// fails: no readable registry.json at the configured/discovered path

tabula/no-runtime-class-construction

Disallows constructing a class string at runtime. Tailwind's scanner only sees complete literal strings, so `p-${n}` compiles to nothing, silently. Allows conditionals (cond && "x", ternaries, arrays/objects of literals), a module-scope const lookup map, an import from a *.classmap.ts module, a cva()/tv()/variants() result, and a ClassName-typed pass-through parameter.

tsx
// ❌ violating
<div className={`p-${size}`} />

// ✅ passing
const PADDING = { sm: 'p-sm', md: 'p-md' } as const;
<div className={PADDING[size]} />

tabula/no-unregistered-arbitrary-value

Disallows any [...]-containing candidate (arbitrary value, property, variant, or modifier) that isn't registered as an exception. Never autofixed.

tsx
// ❌ violating
<div className="w-[347px]" />

// ✅ passing — after `tabula except add` registers it
<div className="w-hero-legacy-width" />

tabula/no-unknown-class

Disallows a class whose utility isn't registered in the vocabulary. Reports the registry-independent banlist first (so space-x-4 explains why it's banned rather than suggesting a typo fix), then offers Levenshtein-≤2 "did you mean?" suggestions for the rest. Never autofixes.

tsx
// ❌ violating
<div className="bg-surfac" />
// Unknown class `bg-surfac`. Did you mean `bg-surface`?

// ✅ passing
<div className="bg-surface" />

tabula/no-theme-variant

Disallows dark:, light:, and any [data-theme…]/[prefers-color-scheme…] variant. Theme is a token axis — bg-surface already carries every theme's value — so a variant reintroduces the branch the axis model removes. Registry-independent (fires with no registry at all).

tsx
// ❌ violating
<div className="bg-white dark:bg-gray-900" />

// ✅ passing
<div className="bg-surface" />

tabula/no-important

Disallows the !important marker; it escapes the rank-decides-cascade model. Offered as a suggestion (not a silent autofix), since removing it can change rendered behavior.

tsx
// ❌ violating
<div className="!p-md" />

// ✅ passing
<div className="p-md" />

tabula/class-order

Enforces canonical ordering — condition band, then ascending utility rank — on any class string whose tokens are fully known, non-arbitrary, and non-!important. Autofixes by reordering (behavior-preserving, since order within one class attribute doesn't affect the computed style once the merge is sound).

tsx
// ❌ violating
<div className="hover:bg-surface-raised bg-surface" />

// ✅ passing (autofixed)
<div className="bg-surface hover:bg-surface-raised" />

tabula/no-conflicting-classes

Disallows two known classes in one static string that share a condition and either intersect the same slots (redundant — autofixed by keeping the higher-rank class) or pair an atomic class (e.g. sr-only) with a slot-writing class declaring the same CSS property (ambiguous — no autofix; the author must choose).

tsx
// ❌ violating (autofixed to `p-8`)
<div className="p-4 p-8" />

// ❌ violating, no autofix — both declare `position`
<div className="sr-only absolute" />

tabula/require-merge

Requires a className expression combining two or more class sources (+ concatenation, an array literal) to go through cn(); an un-merged concatenation has undefined precedence. Autofixes by wrapping the operands. Template-literal combining is no-runtime-class-construction's business instead.

tsx
// ❌ violating
<div className={base + ' ' + className} />

// ✅ passing (autofixed)
<div className={cn(base, className)} />

tabula/classname-last

Requires the className argument to be the last argument of a cn()-family call, so a caller's override always wins. Autofixes by moving it to the end (skipped under a spread argument, where reordering isn't safe).

tsx
// ❌ violating
<div className={cn(className, 'p-md')} />

// ✅ passing (autofixed)
<div className={cn('p-md', className)} />

tabula/named-group-only

Requires every group/peer marker and consumer to carry a name (group/card, group-hover/card:); a bare group makes "which ancestor is this?" unanswerable without reading the whole tree.

tsx
// ❌ violating
<div className="group"><span className="group-hover:opacity-100" /></div>

// ✅ passing
<div className="group/card"><span className="group-hover/card:opacity-100" /></div>

tabula/group-marker-exists

Requires a named group-* consumer to have a matching group/<name> marker on a same-file ancestor. Downgrades to TAB-W301 (never an error) when the marker can't be found in this file — it may legitimately live in a parent component the linter can't see.

tsx
// ⚠ warning — no `group/card` ancestor found in this file
<span className="group-hover/card:opacity-100" />

tabula/peer-source-order

Requires a named peer-* consumer to follow its peer/<name> marker sibling in source order, mirroring the DOM's own ~ requirement for the :has()/general-sibling relationship peer relies on.

tsx
// ⚠ warning — `peer/email` must precede this element
<span className="peer-invalid/email:text-danger" />
<input className="peer/email" />

tabula/inherited-property-boundary

Under the base profile level only (inert under strict), restricts an inheritable-property utility (text-*, font-*, leading-*, tracking-*, ink-*) to a text-leaf tag or an element explicitly marked scope-text — the compile-time half of containing the one mechanism in base typography that inherits. Autofixes by inserting scope-text on the offending intrinsic element; a component boundary can't be autofixed and downgrades to TAB-W301.

tsx
// ❌ violating — <div> is a container, not a text leaf
<div className="text-sm">…</div>

// ✅ passing
<div className="scope-text text-sm">…</div>

tabula/exception-scope

Restricts a registered exception class to its allowedIn globs and expires date; using it out of scope or past expiry quietly reopens the closed vocabulary the exception's own paperwork exists to bound.

tsx
// ❌ violating — used outside tokens/exceptions.tokens.json's allowedIn glob
<div className="w-hero-legacy-width" />  // in a file not matching `src/marketing/hero.tsx`

Exported configs

ExportWhat it does
tabula.configs.strictEvery rule at error.
tabula.configs.migrationThe four ban rules (no-runtime-class-construction, no-unregistered-arbitrary-value, no-theme-variant, no-important) stay error; everything else is warn, for incremental adoption.

@tabula-css/eslint-plugin/banlist

ts
import { BANLIST, betterTailwindcssBanlist, matchBan } from '@tabula-css/eslint-plugin/banlist';

Re-exports @tabula-css/core's registry-independent ban table: BANLIST (the frozen pattern list — space-*, divide-*, *:, **:, arbitrary combinators, in-*, rtl:/ltr: — see Banned mechanisms), matchBan(token) (returns the matching ban id or undefined), and betterTailwindcssBanlist() for wiring the same patterns into a project's own eslint-plugin-better-tailwindcss config.

See also

Released under the MIT License.