Skip to content

Migration

Scope note, read first. Two tools drive this migration and they are meant to be used together. tabula migrate does the mechanical part: four codemods that rewrite only what is provably 1:1 and leave a located TODO comment plus a diagnostic everywhere else. The ESLint migration preset does the judgement part: it turns every remaining banned pattern into a visible, located warning you resolve by hand. Nothing here is a one-command conversion, by design — a codemod that guessed a designer's intent would produce a change nobody reviewed, in a file its author believes was migrated. The one transform the SPEC (.orchestrator/SPEC.md, J6) describes that is deliberately not automated is dark:-hoisting; §4 says why.

Upgrading Tabula v0.1.0 → v0.2.0

v0.2.0 closes the variant-closure defect: in v0.1.0 every variant-prefixed class (hover:bg-accent-hover, sm:p-md, any chain) emitted no CSS at all, because the preset whitelisted only base classes into @source inline. v0.2.0 makes variant chains a first-class part of the registered set via declared variant products (see concepts.md § Variants). What this means for an existing project:

  • A rebuild is required — registry schema v3 is a breaking change. The registry schema version bumps to 3 (the registry gains variantProducts, chainCount, and chainExceptions, plus resolved media conditions), so sourceHash/cssHash change and the committed .tabula/ goes stale on upgrade. A v0.1.0 registry (schema v2) is rejected by the v0.2 merge runtime and every registry loader (TAB-E302) until tabula build regenerates it — run it before anything else; tabula doctor and build --check will fail until you do. This is the ordinary drift/rebuild path, not a manual migration.
  • Variants now emit CSS. A chain your code already used (e.g. hover:bg-accent-hover) starts producing a rule once its product is declared — the class that silently did nothing before now works.
  • The interaction preset is the default. With no variants section in tabula.config.json, the profile declares every self-state variant (hover, focus, focus-visible, focus-within, active, disabled) over the interactive families, plus placeholder over ink/caret/accent. This fixes the shipped defect out of the box for the common case.
  • To declare more, add a variants.products map (chain prefix → families, "*" for all non-atomic families) or switch variants.preset to all-len1; for a single one-off chain use tabula except add --chain <chain>. Media variants (sm:, …) require a breakpoint axis in your tokens (TAB-E170 otherwise), and product expansion is capped by variants.maxChainCandidates (default 20,000, TAB-E172 over budget).

tabula migrate

tabula migrate logical    # pl-* → ps-*, and the rest of the physical → logical axis
tabula migrate spacing    # space-x/y-* → gap-x/y-*, only where the axis is provable
tabula migrate merge      # clsx / classnames / tailwind-merge imports → @tabula-css/merge's cn
tabula migrate dark       # report-only: every dark:/light:/[data-theme=…] usage

Every subcommand is a dry run by default and prints a real unified diff (--- a/…, @@ hunk headers) that pipes into git apply or any review tool. Add --write to apply it. Nothing is ever rewritten outside a class sink — a className/class attribute or an argument to cn, clsx, cva, tv, … — and only inside static string regions, so a comment, a URL constant, an id or an alt that happens to contain pl-4 is untouched structurally rather than by a check that could be forgotten. A file that will not parse is reported and skipped; there is no regex fallback.

The exit codes are the agent-facing contract, and 1 and 2 are never blurred:

ExitMeaning
0Nothing to do, or --write applied everything and left nothing outstanding.
1Migration work is outstanding: a dry run with pending rewrites, or any TODO-flagged or report-only finding.
2The tool is broken or misdriven: unknown subcommand, unloadable project, or a missing registry where the subcommand needs one.

Both channels always answer, including on the failure paths. The human channel gets the diff and a summary; the machine channel gets an envelope in which every finding is a diagnostic carrying file, line, col, a subject and at least one runnable fix. Nothing exists only in the diff, so an agent reading --format=json never has to parse one.

From vanilla Tailwind v4

1. Install and adopt incrementally

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

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

migration keeps four rules as hard errors — no-runtime-class-construction, no-unregistered-arbitrary-value, no-theme-variant, no-important — and relaxes the rest (no-unknown-class, class-order, no-conflicting-classes, …) to warnings, so you can land the switch file by file instead of all at once. Run it before you've even written a tokens/*.tokens.json file — the four hard-error rules are registry-independent.

2. Physical → logical inline axis

pl-* pr-* ml-* mr-* left-* right-* border-l-* border-r-* text-left text-right are simply not registered — Tabula only ever registers the logical forms (ps-* pe-*ms-* me-* start-* end-* border-s-* border-e-* align-start align-end). Every physical-axis class in your codebase will surface as tabula/no-unknown-class, and because the logical and physical names differ by exactly one or two characters (pl-4ps-4), the rule's built-in Levenshtein "did you mean" almost always names the correct replacement directly in the lint output. There is no ESLint autofix for this rule (deliberately — see its header comment: an unknown class needs a human or agent decision, not a blind rewrite).

This is the one axis where a codemod is provably 1:1, so it has one:

tabula migrate logical            # preview the diff
tabula migrate logical --write    # apply it

The mapping is a statement about the physical → logical axis, which is true of every project whatever its tokens are, so this subcommand needs no built registry and rewrites unconditionally. Two consequences worth internalising:

  • It fixes the axis, not the value. If you write pl-4 and have no 4 spacing token, the result ps-4 is still unregistered and still emits no CSS. tabula scan is the gate that catches that; the command prints the same reminder.
  • text-left becomes align-start, not text-start. align-start/align-end are what @tabula-css/core's static-utility table actually registers, and emitting text-start would migrate every text-left in a codebase to a class that compiles to nothing.

Matching happens on a whole class token, after variants and any negation sign are stripped — so place-content-center, border-large and xpl-4 are left alone, variant chains carry through (md:hover:border-l-2md:hover:border-s-2), and negatives map to the negatable logical families (-ml-4-ms-4). Inside a template literal the static quasis are rewritten, but a fragment abutting an interpolation is not: in `pl-${n}` the text pl- is a class fragment whose real value the codemod cannot see, so guessing there is refused.

3. space-* / divide-*gap-* and per-child borders

These aren't in the registry either, so they also surface via no-unknown-class — but as a generic "unknown class" rather than a banned-mechanism explanation. Two better options for seeing why, not just that:

  • Ask tabula-mcp's explain_ban tool (or tabula explain) about the specific class — it returns the mechanism's reason and its replacement, never a bare "not found".
  • If you already run eslint-plugin-better-tailwindcss, spread betterTailwindcssBanlist() (exported from @tabula-css/eslint-plugin/banlist) into its no-restricted-classes option for the same messages inline in your own editor.

Replace space-y-4 on a parent with gap-y-md (or your nearest spacing token) plus flex flex-col; replace divide-y with a <Separator /> between children (@tabula-css/react ships one) or a border utility on each child directly. Both changes move the styling from the parent's markup into the child's — which is the whole point.

tabula migrate spacing does the subset of that which is provable, and flags the rest:

tabula migrate spacing --write

It rewrites space-x-*gap-x-* and space-y-*gap-y-* on one element only when all of these hold on that element's own class string: it carries flex (or inline-flex); it carries an explicit flex-row/flex-col matching the axis; no variant-prefixed display or direction class changes the axis at some breakpoint; and the target gap-x-*/gap-y-* class is actually in your registry. Anything else gets a TODO(tabula migrate spacing) comment naming the exact reason plus a diagnostic — never a rewrite.

Four refusals are deliberate rather than unimplemented:

  • Bare flex with no explicit direction is refused. flex-direction: row is the CSS initial value, so flex alone is row today — but a responsive variant, a parent stylesheet or a style prop can change it, and none of those are visible from the class string. Refusing costs you one word (flex-row); accepting costs a silently wrong layout.
  • grid is never auto-rewritten. space-x-*'s margin applies to every child after the first in DOM order, which stops corresponding to column-gap the moment items wrap to a second row.
  • Never plain gap-*. gap, gap-x and gap-y are three separate families; collapsing to gap-* would silently add spacing on the other axis.
  • It never invents a value. If gap-y-4 is not registered, space-y-4 is flagged, not rewritten to the nearest-looking token.

Unlike migrate logical, this subcommand requires a built registry — the target is a value, not an axis, and only the registry knows whether md is a real spacing token. Without one it exits 2 and tells you to run tabula build, because "I cannot tell whether this rewrite is safe" is a broken tool, not a violating project.

4. dark: → theme axis tokens

no-theme-variant flags every dark:/light:/[data-theme=…]: variant as an error under both presets (it's registry-independent — a pure syntactic check). There's no autofix, because the fix requires a value only you have: the token's other theme's literal. For each flagged component:

  1. Find or create the color token in tokens/*.tokens.json with a $axis: "theme" value carrying both literals (see concepts.md § Theming).
  2. Replace bg-white dark:bg-gray-900 with the one token utility, e.g. bg-surface.
  3. Delete the dark: class entirely — the token utility already carries both values.

tabula migrate dark gives you the worklist for that, with file:line:col for every dark:/light:/[data-theme=…] usage and the token-axis explanation attached to each. It is report-only, and --write changes nothing — that is the point of the subcommand, not a missing feature. Two things make the transform unautomatable. The destination is a token file, not the class string: the class shrinks to one name and the information moves into tokens/*.tokens.json. And producing that token requires the token's other theme literal, which exists nowhere in the source when only one dark: class is present and cannot be derived from the light value by any rule. A codemod would have to invent it — precisely the fabrication this profile is engineered against, and worse than no codemod because its output looks reviewed. So the command reports, and points at the paths that do have the missing value: you, tabula except add, and the MCP propose_token / get_tokens tools. Any usage at all is exit 1, which is the intended signal: there is theme work here that no tool can do.

5. Arbitrary values → registered tokens or exceptions

no-unregistered-arbitrary-value is a hard error in both presets. For each [...] value: check tokens.resolved.json / find_class_for for an existing token close enough; if none fits, run tabula except add (see getting-started.md § 7) to mint a named, owned, expiring class instead of leaving the bracket syntax in place.

From shadcn/ui

shadcn/ui and Tabula solve overlapping problems (a small owned component set, Tailwind-based, agent-friendly by design) with different mechanisms. What changes:

cn()@tabula-css/merge's cn()

shadcn's cn = (...inputs) => twMerge(clsx(inputs)) merges by name-shape heuristicstailwind-merge guesses which utilities conflict from their prefixes, and it explicitly does not resolve an arbitrary-value vs. utility conflict (twMerge('p-4 [padding:1rem]') keeps both, letting stylesheet order silently decide the winner). @tabula-css/merge's cn() has the same call shape — cn(...classValues) — but merges by registry-declared slot ownership: it is mathematically sound (T2) rather than heuristic, and every genuinely-composable pair (e.g. shadow-md + ring-2) is a tested golden case rather than an accident of naming. Swap the import; the call sites don't need to change shape, though every class now has to be one your registry actually contains.

tabula migrate merge swaps the imports and leaves every call site alone, which is what forces the aliasing: a file with import clsx from 'clsx' and forty clsx(...) calls becomes import { cn as clsx } from '@tabula-css/merge'. The binding name stays yours; only its origin changes. Three behaviours are worth knowing:

  • twMerge is rewritten and flagged. The destination is the sound one, but the merge semantics genuinely change — heuristics to slot ownership — so every call site needs review. The command emits a TODO comment and a warning saying so, and the run stays exit 1 even after --write, so it can never be mistaken for a mechanical no-op.
  • Only a sole specifier is rewritten. import clsx, { type ClassValue } from 'clsx' is left alone with a flag: ClassValue may or may not exist under that name in @tabula-css/merge, and rewriting the declaration would either drop a binding or assert an export the command has not verified. Split the declaration and re-run.
  • It never produces a duplicate binding. If the local name is already imported from @tabula-css/merge in that file, the stale import is deleted rather than rewritten — a duplicate local binding is a syntax error.

cvavariants()

Same idea (a base string plus named variant groups plus defaultVariants), reimplemented in @tabula-css/merge as a static, literal config so the same ESLint vocabulary checks that cover a plain class string also cover every string inside it. See getting-started.md § Variants for the shape; button.tsx in examples/reference-ui is a full worked conversion from the shadcn Button pattern.

:root / .dark CSS variable pairs → axis tokens

shadcn's theme file defines CSS custom properties twice — once under :root, once under .dark — and components read them through Tailwind's @theme inline bridge. Tabula's answer is the axis model in concepts.md: one token, one $axis: "theme" value with both literals, resolved into :root[data-theme="dark"] at build time. Two concrete changes: delete the .dark { --variable: ... } block and fold its values into the token's axis map; and never write @theme inline in your own CSS — it's a banned mechanism here (it bypasses the axis re-pointing that makes the theme switch actually work).

className pass-through → typed ClassName + cn(..., className) last

Both ecosystems already put className last by convention; Tabula makes it a lint rule (tabula/classname-last, autofixable) and types the prop ClassName (a branded string @tabula-css/merge exports) so tabula/no-runtime-class-construction recognizes the destructured prop as a sanctioned pass-through rather than flagging it as unchecked runtime construction.

What stays

The component shape — a forwarded ref, a typed props interface, composition over configuration — is unchanged; nothing about Tabula requires Radix primitives or shadcn's copy-in file layout to be removed. @tabula-css/react does not attempt to replace Radix — it ships three small primitives (<Text>, <Separator>, <Prose>) that exist specifically to give you a paved-path replacement for the three mechanisms this profile removes (partial typography classes, divide-*, the prose plugin's descendant selectors). Everything else in a shadcn-style component — the accessible interaction logic, the compound-component structure — is orthogonal to the styling layer and needs no change.

Restoring a profile from .tabula/

A committed .tabula/ is enough to reconstruct the source profile it was built from, near-losslessly. The forward build is provenance-preserving by construction: tokens.resolved.json carries each token's $deprecated and $extensions verbatim — so $extensions.tabula.contrastWith accessibility contracts and any foreign vendor namespace (for example a com.example.figma reference) come back intact — and a $alias marker records the dot-path whenever the source $value was a single top-level alias reference like {color.surface}, so the reference is restored rather than a flattened literal. Alongside it, tabula.config.json is a byte-identical copy of the config that built the directory, so the axes, profile level, and variant products need not be guessed. To restore, read tokens.resolved.json, turn each $alias back into its {path} reference, carry $extensions/$deprecated across, and pair the tokens with the copied config.

The one remaining loss class: a nested or partial alias inside a composite value — an alias used as one field of a type composite, one layer of a shadow, or one member of an axis map — is not marked and comes back as its resolved literal, because only a single top-level {path} $value is recorded. Everything else round-trips to the same vocabulary.

Ejecting off Tabula entirely

Restoring rebuilds the source; ejecting (experimental) goes the other way — it freezes the output. tabula eject copies a verified .tabula/ into a project-owned directory that compiles on stock @tailwindcss/cli with zero class changes, and stops the token pipeline flowing to it. It is a one-way door: after eject there are no rebuilds, no scan gates, and no drift checks, and token changes no longer reach the frozen copy. The runtime cn() still needs @tabula-css/merge and the copied registry.json — replacing it with tailwind-merge changes rendered output. See Eject for the full flow, the four hazards it handles, and the exact command surface.

Released under the MIT License.