Migration
Scope note, read first. Two tools drive this migration and they are meant to be used together.
tabula migratedoes the mechanical part: four codemods that rewrite only what is provably 1:1 and leave a locatedTODOcomment plus a diagnostic everywhere else. The ESLintmigrationpreset 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 isdark:-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, andchainExceptions, plus resolved media conditions), sosourceHash/cssHashchange 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) untiltabula buildregenerates it — run it before anything else;tabula doctorandbuild --checkwill 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
interactionpreset is the default. With novariantssection intabula.config.json, the profile declares every self-state variant (hover,focus,focus-visible,focus-within,active,disabled) over the interactive families, plusplaceholderoverink/caret/accent. This fixes the shipped defect out of the box for the common case. - To declare more, add a
variants.productsmap (chain prefix → families,"*"for all non-atomic families) or switchvariants.presettoall-len1; for a single one-off chain usetabula except add --chain <chain>. Media variants (sm:, …) require abreakpointaxis in your tokens (TAB-E170otherwise), and product expansion is capped byvariants.maxChainCandidates(default 20,000,TAB-E172over 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=…] usageEvery 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:
| Exit | Meaning |
|---|---|
0 | Nothing to do, or --write applied everything and left nothing outstanding. |
1 | Migration work is outstanding: a dry run with pending rewrites, or any TODO-flagged or report-only finding. |
2 | The 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
// 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-4 → ps-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 itThe 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-4and have no4spacing token, the resultps-4is still unregistered and still emits no CSS.tabula scanis the gate that catches that; the command prints the same reminder. text-leftbecomesalign-start, nottext-start.align-start/align-endare what@tabula-css/core's static-utility table actually registers, and emittingtext-startwould migrate everytext-leftin 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-2 → md: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'sexplain_bantool (ortabula 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, spreadbetterTailwindcssBanlist()(exported from@tabula-css/eslint-plugin/banlist) into itsno-restricted-classesoption 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 --writeIt 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
flexwith no explicit direction is refused.flex-direction: rowis the CSS initial value, soflexalone is row today — but a responsive variant, a parent stylesheet or astyleprop 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. gridis never auto-rewritten.space-x-*'s margin applies to every child after the first in DOM order, which stops corresponding tocolumn-gapthe moment items wrap to a second row.- Never plain
gap-*.gap,gap-xandgap-yare three separate families; collapsing togap-*would silently add spacing on the other axis. - It never invents a value. If
gap-y-4is not registered,space-y-4is 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:
- Find or create the color token in
tokens/*.tokens.jsonwith a$axis: "theme"value carrying both literals (see concepts.md § Theming). - Replace
bg-white dark:bg-gray-900with the one token utility, e.g.bg-surface. - 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 heuristics — tailwind-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:
twMergeis 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 aTODOcomment and a warning saying so, and the run stays exit1even 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:ClassValuemay 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/mergein that file, the stale import is deleted rather than rewritten — a duplicate local binding is a syntax error.
cva → variants()
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.