Design Token Lint
Build-time enforcement of a design token system — forbid raw color literals, raw z-index integers, and zone-aware misuse, with a documented escape hatch.
The Problem
Methodology articles document what tokens to define. They stop short of how to enforce them at build time. Without enforcement, a token system decays:
- Raw
#0066ffliterals creep into component CSS, slipping past code review. z-index: 99shortcuts bypass the tier system. Once one slips in, the next contributor sees99and writes100.- Token names drift between docs and CSS as the codebase grows. The
--shadow-modaldocumented in the design-system page no longer matches the--modal-shadowactually used in three components. - “We agreed not to use raw oklch in components” survives in code review for about six months. After that, it survives only in the heads of two people, neither of whom is reviewing today’s PR.
A token system without enforcement is a code-review hope, not a guarantee. AI-assisted refactoring makes the rotation worse: tokens move faster, contributors change more often, and the rules that “everyone knows” get re-learned by every new agent and contributor.
The Solution
Run a multi-pass linter at pre-push and CI time that scans component code and CSS for known anti-patterns. Each pass enforces a different rule. New rules can be added as the design system grows.
The minimal pattern, in three passes:
| Pass | Scans | Forbids | Allows |
|---|---|---|---|
| Pass 1 | Component class lists / CSS values | Raw color literals (#rrggbb, oklch(...), rgb(...), default Tailwind colors) | Semantic tokens (bg-surface, text-fg), arbitrary values inside escape hatch |
| Pass 2 | Zone-defining blocks (:root, @theme, [data-theme="..."]) | Semantic-tier tokens that embed literals directly | Semantic-tier tokens that reference palette tokens via var() / color-mix() |
| Pass 3 | Component CSS | Raw z-index: <integer> | var(--z-*), calc(... var(--z-*) ...), keywords (auto, inherit, initial) |
Pass 1 is the most common — it is what @takazudo/zudo-design-token-lint ships today for Tailwind class names. Pass 2 and Pass 3 are the conceptual extensions that grow naturally from the same pattern.
📝 Pass 3 status
Pass 3 (raw z-index integers) is planned, not shipped. It will land alongside the Z-Index Strategy article’s tier system. Track upstream status at zudo-pattern-gen#942. Document the rule now so the contract is clear; ship the implementation when the upstream is ready.
Pass 1: forbid raw values in component code
The simplest pass. Scan every file that produces visible UI. Flag any literal that should be a token.
/* component.css — caught by Pass 1 */
.alert {
background: #ffe4e4; /* raw hex literal */
color: oklch(45% 0.18 27); /* raw oklch literal */
padding: 12px 16px; /* raw spacing literal */
}
/* component.css — passes Pass 1 */
.alert {
background: var(--color-alert-bg);
color: var(--color-alert-fg);
padding: var(--space-sm) var(--space-md);
}
For Tailwind-class projects, the same rule applies to numeric utilities and default colors:
// Caught by Pass 1
<div className="p-4 bg-gray-500 text-blue-600">
// Passes Pass 1
<div className="p-hgap-sm bg-surface text-fg">
The point is not the specific syntax — it is that every raw literal in component code is a violation by default. The violation can be excused with an escape hatch, but it cannot be silent.
Pass 2: zone awareness for semantic tokens
Pass 1 alone is too broad. Some files must contain raw literals — that is the whole point of a :root block:
:root {
/* Palette: literal oklch is correct here. */
--p0: oklch(98% 0 0);
--p15: oklch(15% 0 0);
}
If Pass 1 flagged every literal in :root, the palette layer could not exist.
The fix is zone awareness. Mark :root, @theme, and [data-theme="..."] as token-definition zones. Inside a zone, palette-tier tokens (--p0 … --p15) may embed literals. Semantic-tier tokens defined in the same zone must still reference palette tokens via var(...) / color-mix(...). They cannot embed literals directly.
:root {
/* Pass 1 allows literals in this zone. */
--p0: oklch(98% 0 0);
--p15: oklch(15% 0 0);
/* Pass 2 catches this. The token name --shadow-modal */
/* is semantic, but it embeds a raw oklch literal. */
--shadow-modal: 0 12px 32px oklch(15% 0 0 / 0.4);
}
:root {
--p0: oklch(98% 0 0);
--p15: oklch(15% 0 0);
/* Passes Pass 2 — the semantic shadow references the palette. */
--shadow-modal: 0 12px 32px color-mix(in oklch, var(--p15), transparent 60%);
}
The distinction is what makes “raw oklch in :root” still a lint failure when the variable being defined is a semantic token. Without Pass 2, a :root block becomes a back door: any literal can hide behind any name.
How does the linter know the difference? By naming convention. Palette tokens use a reserved prefix (--p0 … --p15, --p-blue-500, whatever the project chooses). Anything else defined in a token zone is treated as semantic and must reference the palette. The convention has to be explicit and documented; the linter does not guess.
💡 Convention before enforcement
Pass 2 only works if the project has a clear naming convention for palette tokens. Document it once in the design-system page, then encode it in the linter config. If the convention is fuzzy, the lint rule will be too.
Pass 3: forbid raw z-index integers
Raw z-index: <integer> in component CSS undermines the Z-Index Strategy tier system. Pass 3 forbids them.
/* component.css — caught by Pass 3 */
.modal {
position: fixed;
z-index: 100;
}
.toast {
position: fixed;
z-index: 9999;
}
/* component.css — passes Pass 3 */
.modal {
position: fixed;
z-index: var(--z-modal);
}
.toast {
position: fixed;
z-index: var(--z-toast);
}
Allowed forms:
z-index: var(--z-modal);— direct token referencez-index: calc(var(--z-modal) + 1);— token-derived calcz-index: auto;/inherit;/initial;— keyword values- Raw integer with a documented escape hatch (next section)
Forbidden: bare integers, no exceptions, no escape hatch. z-index: 100; in component CSS is a Pass 3 failure.
The companion Z-Index Strategy article documents the tier-token system that Pass 3 enforces. Pass 3 status: planned at zudo-pattern-gen#942.
Escape hatches
Some violations are legitimate: a one-off third-party widget integration, a debugging colour, an experimental layer. Block by default — but never block forever. Document the escape hatch and require a comment.
The canonical syntax exposed by @takazudo/zudo-design-token-lint is:
{/* design-token-lint-ignore */}
<div className="p-4 bg-gray-500">
/* design-token-lint-ignore */
.legacy-widget {
background: #0066ff;
}
// design-token-lint-ignore
const className = `p-4 bg-${shade}-500`;
The escape hatch is one line-level comment that suppresses violations on the next code line. Three forms exist for the three comment syntaxes a project will encounter (JSX, CSS, JS/TS line). They are aliases of the same rule.
Two anti-patterns to watch for:
- Escape hatches without a comment.
/on its own is a smell. The rule is “block by default, allow with a documented reason.” Without the reason, the next reader has no way to evaluate whether the exception is still valid.* design- token- lint- ignore */ - File-level escape hatches. A whole-file ignore is almost always a mistake — it silently exempts every future raw literal added to that file. If a file genuinely needs to opt out, a
.design-token-lint.jsonignoreglob is more honest because it appears in the project’s lint config rather than being hidden in a CSS comment.
/* Legacy: this colour matches the old brand asset PNG that finance still uses. */
/* Tracked at #1234 — remove once the asset is regenerated. */
/* design-token-lint-ignore */
.invoice-banner {
background: #0066ff;
}
The lint config also supports allowed, ignore, and prohibited fields for project-wide rules — use those for stable exceptions and let the line-level comment carry the per-occurrence reason.
Reference implementation
<code>@takazudo/zudo-design-token-lint</code> is the working reference implementation. It currently ships Pass 1 for Tailwind class names:
- Forbids numeric spacing utilities (
p-4,m-8,gap-6,mt-16,space-x-4,inset-2, …) - Forbids default Tailwind colors (
bg-gray-500,text-blue-600,border-red-300,ring-indigo-500, …) - Allows semantic tokens (
bg-surface,text-fg,p-hgap-sm), arbitrary values (w-[28px]), zero values (p-0), and any non-default colour name - Static-analysis based: scans
className=,class=,cn(...),clsx(...),classNames(...),twMerge(...)and Astroclass:listexpressions
The same package is vendored in zudo-pattern-gen <code>packages/design-token-lint</code> — the pgen project consumes it as a workspace package, which is one of three legitimate adoption strategies:
- Install from npm —
pnpm add -D @takazudo/zudo-design-token-lint. Simplest, follows upstream releases. - Vendor as a workspace package — what pgen does. Useful when the project needs project-specific rules that have not yet upstreamed.
- Use as a reference design — re-implement the multi-pass pattern with a project-specific rule set and CLI. Useful when the project’s token rules diverge enough that fork is cleaner than configure.
Pass 2 (zone-aware semantic tokens) and Pass 3 (raw z-index integers) are conceptual extensions. The upstream may grow into them; a vendored copy can implement them sooner. Either path, the multi-pass shape is the same: scan, classify, report, exit non-zero on violations.
Worked example: end-to-end
A component CSS file with two violations and one legitimate escape:
/* src/components/legacy-toast.css */
.legacy-toast {
position: fixed;
z-index: 9999; /* Pass 3: raw z-index integer */
background: #ffaa00; /* Pass 1: raw hex literal */
color: var(--color-toast-fg);
}
/* Brand-mandated exact colour for the legacy yellow toast — tracked at #5678. */
/* design-token-lint-ignore */
.legacy-toast--brand {
background: #ffaa00;
}
Running the linter (output format is illustrative — the exact format depends on the implementation):
$ pnpm design-token-lint
src/components/legacy-toast.css
3:11 error Raw z-index integer "9999" — use a --z-* token
4:15 error Raw color literal "#ffaa00" — use a semantic color token
✖ 2 errors
Suggestions:
- Replace "z-index: 9999" with "z-index: var(--z-toast)" (see Z-Index Strategy)
- Replace "#ffaa00" with a semantic token from the design system
The escape-hatched .legacy-toast--brand block does not appear in the output — Pass 1 is suppressed for the next rule, and the comment above it documents why.
Wire it into the project’s pre-push hook so violations cannot reach origin:
# scripts/run-b4push.sh
set -euo pipefail
step "Type check"
pnpm check
step "Build"
pnpm build
step "Design token lint"
pnpm design-token-lint
CI runs the same script. If lint fails, the push fails; if a developer pushes from a different shell, the PR check catches it. The redundancy is intentional — the local hook is for fast feedback, the CI gate is the actual contract.
Anti-patterns
- Adding the linter without wiring it into b4push / CI. A linter that runs only when someone remembers to run it does not enforce anything. The wiring is the enforcement.
- Running only the design-token rules and skipping component-CSS imports of unrelated literals. Transparent gradients,
currentColorchains, and one-off SVG fills also drift. The same multi-pass infrastructure can host other rules cheaply once it exists. - Escape hatches without comments. Block by default; allow with a documented reason. A bare
/becomes load-bearing tech debt within months.* design- token- lint- ignore */ - Treating Pass 1 as the whole pattern. Pass 1 catches obvious raw values. Pass 2 catches the subtle “token name with literal value inside
:root” rotation. Without Pass 2, the design system slowly migrates raw literals into the token-definition zone. - Lint-failing examples inside
<CssPreview>demos. Raw#ff0000in acss={...}payload would violate the project’s own demo conventions (every CSS value should be a validhsl()/oklch()already, not a literal that “demonstrates the bug”). Keep lint-failing examples in fenced code blocks; reserve<CssPreview>for shipped, lint-clean code.
When to Use
Good fit
- Any project with a token system that has grown past a small number of components. The system needs an enforcement boundary; the lint pass is that boundary.
- Multi-contributor teams. Code review catches first-time mistakes; the linter catches the second and third. Without it, the third one ships.
- AI-assisted codebases. AI agents copy from neighbouring files. If one neighbour has a raw
oklch(), every subsequent generation tends to repeat it. The linter is the first reader that does not copy. - Long-lived design systems. Tokens evolve. The linter documents the current contract in machine-readable form so it survives across refactors.
Not needed
- Prototypes and one-off pages. The token system is not yet stable; encoding rules now is premature.
- Single-developer projects with no token system. There is nothing to enforce.
References
- <code>@takazudo/zudo-design-token-lint</code> — reference implementation (Tailwind class lint, Pass 1)
- zudo-pattern-gen <code>packages/design-token-lint</code> — vendored example
- zudo-pattern-gen #942 — z-index Pass 3 status (planned)
- Z-Index Strategy — companion methodology article (Pass 3’s tier system)
- Tight Token Strategy — the token convention that Pass 1 enforces
- Multi-Namespace Token Strategy — naming for projects with several design contexts