Skip to content

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

bash
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/react

Node 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:

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:

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

bash
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.txt

build 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

FileWhat it's for
source.cssThe 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.cssJust the @theme block and root-scoped axis blocks, also embedded into source.css.
profile.cssEvery 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.jsonGround truth. class → declarations, slots, rank, custom properties, exceptions, bans. Everything else derives from this.
tokens.resolved.jsonEvery token's literal value, per axis combination — read before choosing a value by hand.
vocabulary.txtEvery legal class with its declarations and description — read before writing a class.
types.d.tsA TypeScript union of every registered class name: editor autocomplete plus a compile-time closure gate.
llms.txt / llms-full.txt / AGENTS.md.snippetThe agent-facing surface — see agents.md.
manifest.jsonThe integrity root: inputsHash, profileVersion, a sha256 per artifact. What doctor and build --check compare against.
tabula.config.jsonThe 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):

css
@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:

css
@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 —

bash
grep -rn '@source\|@import "tailwindcss"' src/**/*.css

Then write classes straight out of vocabulary.txt:

tsx
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:

tsx
<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.)

tsx
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:

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:

bash
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:

bash
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, budgets

build --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:

bash
npx tabula scan --strict   # exit 1 on any class outside the vocabulary, in any source file type

tabula 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 astro

node_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.

Released under the MIT License.