Getting started
This walks through building a small set of components on Tabula, from an empty project to a CI-checked build. Every command and code block here is real — either taken from examples/reference-ui or run against the actual CLI in this repository's test suite.
1. Install
npm install -D @tabula-css/cli @tabula-css/eslint-plugin
npm install @tabula-css/merge @tabula-css/preset
# optional: paved-path primitives (<Text>, <Separator>, <Prose>)
npm install @tabula-css/reactNode 20+ and Tailwind CSS v4 are required (@tabula-css/preset targets the v4 engine specifically).
2. Author your tokens
Tabula reads every tokens/*.tokens.json file in your project root (there can be more than one — a namespace must live in exactly one file, or the build errors) and merges them. Tokens are a flat, DTCG-shaped profile: two levels deep, every value either a literal or a totally declared axis map — never a $ref, never an alias chain.
tokens/base.tokens.json:
{
"color": {
"surface": {
"$type": "color",
"$description": "Default page background. The bottom-most layer of the UI.",
"$value": { "$axis": "theme", "light": "#ffffff", "dark": "#0b0b0c" }
}
},
"spacing": {
"md": {
"$type": "dimension",
"$description": "Default spacing unit. Card padding and gaps use this.",
"$value": { "value": 1, "unit": "rem" }
}
},
"radius": {
"md": {
"$type": "dimension",
"$description": "Default corner radius for cards and raised surfaces.",
"$value": { "value": 0.5, "unit": "rem" }
}
}
}Every token needs a $description of at least 20 characters (TAB-E109) — this is what powers reverse lookup (find_class_for), so a vague description is a real defect, not paperwork. $axis values must be total: every declared axis member needs a value, with no fallback (TAB-E113) — a token that silently keeps its light value in dark mode is exactly the invisible bug the axis model exists to rule out.
3. Declare your axes
tabula.config.json:
{
"profileId": "my-app@1",
"profileLevel": "base",
"axes": {
"theme": {
"values": ["light", "dark"],
"default": "light",
"attribute": "data-theme",
"media": { "dark": "(prefers-color-scheme: dark)" }
}
},
"groups": []
}profileLevel picks the typography strictness (see concepts.md); base — the default when the field is omitted — keeps standard text-*/font-* utilities under containment rules. axes.theme.attribute is the DOM attribute (data-theme="dark") that switches the theme; media.dark is the prefers-color-scheme fallback for first paint before any attribute is set.
4. Build
npx tabula build✓ wrote 12 artifacts to .tabula/ (inputsHash 67a91fcf6338)
AGENTS.md.snippet
llms-full.txt
llms.txt
manifest.json
profile.css
registry.json
source.css
tabula.config.json
theme.css
tokens.resolved.json
types.d.ts
vocabulary.txtbuild is atomic and fails closed: if any token or config error exists, nothing is written (exit 1, the violation's diagnostics printed). Every artifact carries a @generated <hash> header and is written read-only (0444) — never hand-edit anything under .tabula/; tabula doctor will catch it (TAB-E601) and treat the edit as discarded on the next tabula build.
What each artifact is
| File | What it's for |
|---|---|
source.css | The Tailwind v4 entry: source(none) + the theme + one @utility per registered class + @source inline(...) naming exactly the closed vocabulary. This is what your bundler imports. |
theme.css | Just the @theme block and root-scoped axis blocks, also embedded into source.css. |
profile.css | Every registered utility's CSS, re-emitted in ascending rank order (SPEC J2) — for inspecting the vocabulary's output. Not everything that ships: see below. |
registry.json | Ground truth. class → declarations, slots, rank, custom properties, exceptions, bans. Everything else derives from this. |
tokens.resolved.json | Every token's literal value, per axis combination — read before choosing a value by hand. |
vocabulary.txt | Every legal class with its declarations and description — read before writing a class. |
types.d.ts | A TypeScript union of every registered class name: editor autocomplete plus a compile-time closure gate. |
llms.txt / llms-full.txt / AGENTS.md.snippet | The agent-facing surface — see agents.md. |
manifest.json | The integrity root: inputsHash, profileVersion, a sha256 per artifact. What doctor and build --check compare against. |
tabula.config.json | The canonical form of the config that built this directory — so a frozen .tabula/ states what built it. |
What profile.css does not show
profile.css is re-emitted from registry.json, not captured from the compiled output (emitProfileCss, packages/registry/src/generate/assemble.ts). Its entire body is a single @layer utilities { … } block. So it shows exactly the registered vocabulary and nothing else — in particular it does not show Tailwind's base layer.
That layer still ships. @import "tailwindcss" source(none) turns off source scanning; it does not remove base. Importing .tabula/source.css therefore also delivers preflight — box-sizing: border-box, margin: 0, border: 0 solid, display: block on replaced elements, the form-control resets — applied to every element, present in neither the registry nor vocabulary.txt. grep box-sizing .tabula/profile.css returns nothing; the browser still gets it.
Practically: if an element's computed style shows a property no class of its own sets, preflight is the first place to look, and profile.css will not help you find it. Read node_modules/tailwindcss/preflight.css for the full list.
5. Wire it into your app
Import the generated stylesheet as your Tailwind entry point (adjust the path to your CSS entry file):
@import "../.tabula/source.css";What the tooling checks in your own CSS — and what it still does not
Project stylesheets used to be read by nothing in this toolchain. They are read now, by tabula check:css (which tabula build --check runs unless you pass --no-css) and by @tabula-css/stylelint-plugin in your editor. Five bans apply to every .css file in your project: @apply (TAB-E221), hand-authored rules outside this entry file (TAB-E222), --tb-*/--d-* defined anywhere but :root/html (TAB-E223), @theme inline (TAB-E224) and !important (TAB-E225). The file you just added the @import to is the sanctioned entry: it may hold root-level application CSS, and it is exempt from TAB-E222 and from nothing else. See CSS governance for the whole picture, including how to name a non-conventional entry with --css-entry.
One thing is still yours to police: closure is one line deep. source(none) closes the vocabulary of .tabula/source.css. It says nothing about the rest of your stylesheet, and neither gate flags either of these, because @source and @import are at-rules project CSS is permitted to contain:
@import "../.tabula/source.css";
@source "./src"; /* ← scanning back on: every Tailwind class now emits */
@import "tailwindcss"; /* ← same, without source(none) */The theme namespaces that make those classes work are still defined either way. Under profileLevel: "base" the preset resets only the typography scales that collide with the profile's own families (--text-*, --leading-*, --font-weight-*, --font-*, --tracking-*, in packages/preset/src/index.ts); under strict it emits no reset at all. --color-*, --spacing, --radius-* and --shadow-* stay live under both, so bg-red-500 and p-4 remain fully constructible — they are inert only for as long as nothing tells Tailwind to scan for them. Closure is a property of the generated entry file, not of your project.
So: keep the @import of .tabula/source.css the only Tailwind entry in your project, add no @source line, and grep for the rest before you ship —
grep -rn '@source\|@import "tailwindcss"' src/**/*.cssThen write classes straight out of vocabulary.txt:
import { cn, type ClassName } from '@tabula-css/merge';
export function Card({ className }: { className?: ClassName }) {
return <div className={cn('bg-surface p-md rounded-md', className)} />;
}className is typed ClassName — a branded string type @tabula-css/merge exports — which marks it as a sanctioned pass-through so tabula/no-runtime-class-construction accepts destructuring it straight into cn(). The consumer's override always goes last, so it wins the slots it touches deterministically:
<Card className="p-lg" /> // → "bg-surface rounded-md p-lg" (p-md fully shadowed)Variants
For a component with named variants, variants() (the CVA-equivalent @tabula-css/merge ships) takes a static, literal config — every string in it is checked the same as a plain class string, because the map is just as scannable as source text. (This example reuses class names from examples/reference-ui's richer token set, not the three tokens introduced above.)
import { variants, type ClassName } from '@tabula-css/merge';
const button = variants({
base: 'rounded-md gap-sm inline-flex items-center justify-center',
variants: {
variant: {
solid: 'bg-accent',
outline: 'border-border border-thin',
},
size: {
sm: 'px-sm h-control-sm',
md: 'px-md h-control-md',
},
},
defaultVariants: { variant: 'solid', size: 'md' },
});
<button className={button({ variant: 'outline', size: 'sm', className })} />See examples/reference-ui/src/button.tsx for the full, lint-clean version (focus ring, disabled state, transitions).
6. Set up ESLint
eslint.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 tabula/* rules into an error — unknown classes, banned mechanisms (space-*, divide-*, dark:, arbitrary values, bare group), class-order, same-string slot conflicts, className-last, and more. Adopting into an existing codebase? Use tabula.configs.migration instead: the ban rules (no-runtime-class-construction, no-unregistered-arbitrary-value, no-theme-variant, no-important) stay hard errors, and the rest relax to warnings so you can land the switch incrementally. See migration.md.
7. The exception workflow
Sometimes a registered token genuinely doesn't cover a value you need. tabula except add validates a proposal against the real token document and prints the patch — it writes nothing unless you pass --apply:
npx tabula except add \
--name hero-legacy-width --type dimension --value 347px \
--families w --reason "Legacy marketing hero matches a fixed CMS image at exactly 347px; no rem token this specific exists and none should." \
--owner @growth-team --expires 2027-01-15 --allowed-in "src/marketing/hero.tsx"✓ valid. Add this to tokens/exceptions.tokens.json:
{
"exception": {
"hero-legacy-width": { "$type": "dimension", "$value": { "value": 347, "unit": "px" }, ... }
}
}
✓ will mint class: w-hero-legacy-width
Nothing was written. Apply the patch, then run `tabula build`.reason must be at least 40 characters (an anti-boilerplate check — "needed it" doesn't pass); expires is capped at 12 months out for a named exception, or 90 days for the --literal pressure-valve lane. A near-match existing token (within ~5%) triggers a warning instead of letting you silently re-mint it. Pass --apply to write the patch directly — .tabula/ is then stale until you run tabula build again, and doctor will say so.
8. Check it in CI
Two commands make up the CI gate:
npx tabula build --check # exit 1 if the committed .tabula/ doesn't byte-match a fresh build — then runs the scan gate
npx tabula doctor # staleness, hand-edited artifacts, expiring exceptions, version skew, budgetsbuild --check already includes the scan gate, so those two are the whole gate. Run scan on its own when you want it without the drift check — a pre-commit hook, or a fast per-PR job:
npx tabula scan --strict # exit 1 on any class outside the vocabulary, in any source file typetabula scan — the gate that reads your source
ESLint only sees .ts/.tsx. scan runs Tailwind's own scanner (@tailwindcss/oxide) over your source globs — the same extraction that would produce CSS — and diffs the candidates it finds against the registry, your exceptions and foreignClasses. Findings are reported as TAB-E201 with file:line:col and exit 1.
Default globs (scan.sources in tabula.config.json overrides them; DEFAULT_SCAN_SOURCES in @tabula-css/core) cover src/, app/, pages/ and components/, recursively, for:
js jsx mjs cjs ts tsx mts cts md mdx html vue svelte astronode_modules/, dist/ and .tabula/ are never scanned; add more via scan.ignore.
Tailwind's scanner is deliberately liberal — it extracts every word-shaped run in a file — so reporting every unregistered candidate would flag ordinary prose and identifiers. scan therefore reports a candidate only when it is unregistered and utility-shaped, by one of four independent signals: a banlist hit (registry-independent — it fires even with no .tabula/ at all), arbitrary-value or arbitrary-property […] syntax, a variant chain whose every variant resolves (md:whatever), or a registered family prefix with an unregistered value (p-7 where p-md exists). That makes scan a strong gate on intent and not a literal superset of the linter: the linter knows a string sits in a className, and a context-free scanner cannot.
--strict adds the suppression budget (TAB-W900): it counts eslint-disable comments naming a tabula/ rule — plus blanket disables with no rule list, which silence Tabula rules along with everything else — and fails when the count exceeds budgets.maxSuppressions in your config. That budget defaults to 0, so under --strict the first suppression is the failure. The count is printed on every run, budget enforced or not.
build --check runs the scan gate itself, in --strict mode, after the byte-diff passes — drift first, so a scan finding always means "this source is wrong", never "the registry was stale". Pass --no-scan to skip it and check drift only (for a release job that has already scanned).
--check never writes — it diffs freshly generated bytes against disk in memory. doctor runs, in order: staleness (tokens/config hash vs. the manifest), hand-edits (each artifact's sha256 vs. the manifest — TAB-E601 if it drifted), exceptions (expired ones error; ones expiring within 30 days warn), version skew (mixed @tabula-css/* versions, or an installed Tailwind that doesn't match what .tabula/ was built against), and budgets (the literal-escape count against budgets.maxEscapes, default from @tabula-css/core).
Exit codes are the same shape across every command: 0 ok, 1 a contract violation, 2 the tool itself is broken (malformed input, internal error). Add --format=json to any command for the machine envelope { tabula, ok, inputsHash, diagnostics } — what an agent or CI step should parse, never the human-readable text.
Next
- concepts.md for the theory behind the merge and the banned-mechanism list.
- agents.md if you're wiring an AI coding agent into this project.
- migration.md if you're converting an existing Tailwind or shadcn/ui codebase.