Design Tokens
A token is not a variable that happens to hold a colour. It is a named decision, and the
name is the whole product. #4f46e5 is a fact; --color-action is a decision someone
made and can revisit. Renaming two hundred hex literals to --indigo-600, --indigo-650
buys nothing — you still cannot tell which of them the primary button may use.
Judge any token system by one question: when a decision changes, how many files change? More than one means the naming failed, not the tooling.
1. Three tiers, and the change that proves them
primitive --indigo-600: oklch(0.51 0.19 277) what the value IS
semantic --color-action: var(--indigo-600) what it MEANS
component --button-primary-bg: var(--color-action) where it is USED
Now rebrand from indigo to teal. Primitives: add a teal ramp. You do not rename indigo,
because a primitive names a colour and that colour has not changed. Semantic: one line,
--color-action: var(--teal-600). Component tier and every consumer: untouched. One
edit, whole product.
The same change against a codebase writing bg-indigo-600 in JSX gives a teal product with
indigo buttons, found by a designer three weeks later — because nothing about indigo-600
is wrong. It is exactly the colour it claims to be. That is why the failure is silent.
Tier sizes are diagnostic: a few hundred primitives, 30 to 80 semantic names — the vocabulary the whole team must hold — and a component tier that is nearly empty.
2. Naming is a grammar, not a habit
Fix an order and never deviate: general to specific.
category . concept . property . variant . state
color.bg.surface.raised space.inset.md
color.fg.muted radius.control.sm
color.border.input.focus duration.exit.fast
Names then sort into families, and a missing member becomes an obvious gap.
Never name a semantic token after its value. --color-text-grey is false in dark mode,
so you either lie or duplicate. The subtler trap is the half-semantic name: blue-primary,
primary-blue, brand-purple. These look like roles but hard-code appearance, and fail
exactly when they finally matter, at the second brand or the first high-contrast theme. Value
names belong on primitives and nowhere else.
3. Everything else is a token too
Colour is the easy tier. Systems drift most in what nobody bothers to tokenise: spacing, sizing, radius, border width, shadow, duration, easing, z-index, opacity, typography.
A scale token must state its own relationship. spacing-medium with no defined relation
to spacing-small is a synonym for a number: nobody can predict what comes next, so someone
invents spacing-medium-large. Use ordinal names over a stated generator — base 4, ladder
4/8/12/16/24/32/48/64, so space.2 is 8px. The generator is the token; the numbers are
output.
z-index especially. Unnamed stacking values reach 9999 within a year. Five names —
base, dropdown, sticky, overlay, toast — spaced 100 apart ends it.
4. Composite tokens cost more than they look
The W3C format defines composite types — typography, shadow, border,
transition, gradient, strokeStyle — whose $value is an object. They model intent
well and consume badly: CSS cannot read one property out of an object, so a typography
token must be exploded into font-size, line-height and letter-spacing by the
generator anyway, many tools cannot alias a single sub-property, and overriding one field per
theme means restating the whole object. Author composites where values move together, but emit
the decomposed primitives too.
5. The DTCG format, honestly
The Design Tokens Format Module reached its first stable version, 2025.10, in October 2025, alongside Color and Resolver modules. It is a W3C Community Group report, not a Recommendation: no formal standards-track status sits behind it.
The shape: any object with $value is a token. $type names the type and is inherited
from the nearest ancestor group that declares one; $description, $extensions and
$deprecated are the other reserved keys, and groups may use $extends. Aliases are
written "{color.brand.600}", or as a JSON Pointer via $ref. Colours are objects —
colorSpace, components, optional alpha and hex — not strings, which is what
lets the format carry Display P3 and OKLCh.
Interoperability is real but partial: Style Dictionary shipped first-class DTCG support in v4 against an earlier editors' draft, and full 2025.10 support was still landing in v5. Author in DTCG, but pin the draft your toolchain actually implements.
6. Generate; never hand-maintain a second copy
One source transforms into every consumer:
tokens.json --> tokens.css :root { --space-2: 0.5rem }
--> theme.css @theme { --spacing-2: 0.5rem }
--> tokens.ts export const space = { 2: '0.5rem' } as const
Hand-maintenance fails silently and specifically: someone adds --space-9 to the CSS, does
not add it to the TypeScript, and the two disagree for months because nothing compares them.
Add a CI step that regenerates and runs git diff --exit-code. That one check turns
drift from a slow leak into a failed build.
7. Make misuse a compile error
Emit as const objects and derive key unions:
export const space = { 1: '0.25rem', 2: '0.5rem', 3: '0.75rem' } as const
export type SpaceToken = keyof typeof space
A function taking SpaceToken rejects 7 at compile time instead of rendering a
slightly-off gap nobody notices. Pair it with a lint rule banning raw hex and px literals
in component source, and Tailwind arbitrary values (p-[13px]).
8. Aliases: resolve at build, detect cycles
Resolve references at build time and make both failure classes hard errors. An unresolved
alias is worse than a crash: CSS treats var(--typo) as invalid at computed-value time and
falls back to the inherited value, so a misspelling renders a transparent border rather than
throwing.
A cycle hangs a naive resolver. Walk the graph depth-first with three-colour marking —
unvisited, in-progress, resolved — and on re-entering an in-progress node throw with the whole
path (color.action -> color.brand -> color.action). Keep chains to three hops; deeper
means a missing tier, not a clever one.
9. Themes substitute a set; they never add names
A theme is a different set of values for the same set of names. The DTCG Resolver module
formalises this with sets, modifiers whose contexts map a name such as light
to sources, and a resolutionOrder merging them with later sources winning.
The invariant that matters: every theme defines exactly the same semantic keys. A name present in light and absent in dark is not a smaller theme but a broken one, and it surfaces as an invisible element rather than an error. Diff the key sets in CI.
10. Deprecate; do not delete
Tokens are a public API even inside one repository. Rename by adding the new name, pointing
the old one at it, and marking the old $deprecated: "Use color.fg.muted". Remove only on a
major version.
The failures worth naming
- Hex constants with new names. A flat tier of
--brand-blue-1to--brand-blue-9: nothing can be re-themed, no contrast contract can be stated. - Tokens in three places. CSS variables, a Tailwind config, and a TS file, each hand-edited. They agree on the day they are written and never again.
- Component one-offs.
--card-header-padding-top, used once — a value with a long name. - Undefined scales.
size-medium,size-large,size-larger,size-xl-alt. - Aliases as the whole system. Five hops to a hex with no tier boundaries, so nobody knows which level to edit.
Rules
MUST NOT — Do not name a semantic token after its value, hue, or appearance — including part-semantic names such as blue-primary, brand-purple, or color-text-grey.
Why: An appearance-derived name states a fact that only holds in one theme. The moment a second theme, brand, or contrast mode exists, the name is either false or must be duplicated, and a name that lies is worse than a literal because readers trust it.
Incorrect:
{ "color": { "blue-primary": { "$value": "{color.blue.600}" } } }
Correct:
{ "color": { "bg": { "action": { "$value": "{color.brand.600}" } } } }
MUST — Provide a semantic tier between primitive values and component usage, and let application and component code reference only semantic or component tokens.
Why: A theme, a rebrand, or a contrast fix is a change to the mapping from meaning to value. Code bound directly to a primitive has no mapping to change, so every such change becomes an edit to every consumer, and the edit cannot be verified because a primitive is never wrong about itself.
Incorrect:
.card { background: var(--neutral-50); border: 1px solid var(--neutral-200); }
Correct:
.card { background: var(--color-bg-surface); border: 1px solid var(--color-border-default); }
MUST — Give every numeric scale a documented generator — a base unit, ratio, or explicit ladder — rather than opaque size names with no stated relationship between steps.
Why: A name like spacing-medium carries no information about its relationship to spacing-small, so nobody can predict the next step or choose between two adjacent ones. The predictable outcome is invented intermediates such as medium-large, which destroys the scale precisely because the scale was never defined.
Incorrect:
{ "space": { "small": {}, "medium": {}, "medium-large": {}, "large": {} } }
Correct:
{ "space": { "$description": "Base 4. Ladder 4/8/12/16/24/32/48/64.", "1": {}, "2": {}, "3": {}, "4": {} } }
MUST — Derive every token artefact — CSS custom properties, Tailwind theme, TypeScript constants, platform payloads — from one source file by a build step.
Why: Parallel hand-maintained copies diverge the first time someone adds a token under time pressure, and the divergence is invisible because nothing in the repository compares the files. Generation makes the copies functions of one input, so they cannot disagree.
MUST — Fail the token build on any unresolved alias or reference cycle, and report the full path of a cycle rather than a single node.
Why: An unresolved reference is silent at runtime because CSS treats an undefined custom property as invalid at computed-value time and falls back to the inherited or initial value, so the defect appears as a transparent or mis-coloured element far from its cause. A cycle non-terminates a naive resolver, and the path is the only diagnosable part of it.
Incorrect:
Error: circular reference detected
Correct:
Error: alias cycle: color.bg.action -> color.brand.default -> color.bg.action
MUST — Define exactly the same set of semantic token names in every theme; a theme may change values but must never add or omit names.
Why: A name missing from one theme resolves to nothing rather than raising an error, so the component renders with a transparent background or an inherited colour only in that theme. Key-set parity is a set difference that CI can compute exactly, which makes it one of the few token invariants that is cheap and total.
MUST — Rename or retire a token by adding the replacement, aliasing the old name to it, and marking the old name deprecated with the replacement named; remove it only in a major version.
Why: Token names are a public interface even inside one repository, and consumers have no way to discover a removal except by observing broken output. A machine-readable deprecation carrying the replacement name lets a codemod migrate consumers and lets an agent avoid the stale name without being told.
Correct:
{ "color": { "fg": { "secondary": { "$value": "{color.fg.muted}", "$deprecated": "Use color.fg.muted" } } } }
SHOULD NOT — Do not write raw colour, spacing, radius, or duration literals in component source, including Tailwind arbitrary-value syntax such as p-[13px].
Why: Every literal is a decision made outside the system and therefore invisible to every future change of that decision. Arbitrary-value syntax is the most damaging form because it looks like idiomatic framework usage while bypassing the scale entirely.
Exceptions:
- Values with no design meaning, such as a 1px optical nudge or a translate distance derived from a measured element.
SHOULD NOT — Do not let an alias chain exceed three hops from component token to primitive value.
Why: Each hop is a level at which a value could legitimately be overridden, so a chain longer than the tier count means there are levels with no defined meaning. Nobody can then decide which link to edit, and edits land at whichever level the author happened to find first.
SHOULD NOT — Do not create a component token that wraps a semantic token for one consumer without adding a new decision.
Why: Indirection is paid for by the reader on every lookup and is repaid only when more than one consumer shares the value or when the value is genuinely overridable. A single-use wrapper is a long name for a value, and it inflates the tier that should stay smallest.
Exceptions:
- Structural dimensions specific to one component that consumers are expected to override, such as a sidebar width or a control height.
SHOULD — Name every token with a fixed general-to-specific grammar of category, concept, property, variant, and state, and apply the same order throughout the set.
Why: A consistent left-to-right ordering makes names sort into families, makes autocomplete useful after the first segment, and makes a missing member of a family visible as a gap. Ad-hoc ordering produces synonyms that nobody can search for, so tokens get re-invented rather than reused.
SHOULD — Run the token build in CI and fail the job if regeneration produces any diff, so hand-edits to generated files are rejected.
Why: Generation prevents drift only while everyone runs the generator. A regenerate-and-diff step converts a silent inconsistency that surfaces months later into a failed build with the offending diff as its error message.
Correct:
pnpm tokens:build && git diff --exit-code -- packages/tokens/dist
SHOULD — Emit token constants with as const and derive key-union types so that referencing a token that does not exist is a compile error.
Why: An unknown token name is otherwise a runtime non-event: CSS resolves an undefined custom property to the inherited or initial value, so a typo renders something plausible instead of failing. Moving the check to the type system converts an invisible visual defect into a build failure at the point of the mistake.
Incorrect:
export const space: Record<string, string> = { 1: '0.25rem', 2: '0.5rem' }
Correct:
export const space = { 1: '0.25rem', 2: '0.5rem' } as const
export type SpaceToken = keyof typeof space
SHOULD — Version the token package on names and meanings, not values: changing what an existing token means is a major release even when the name is unchanged.
Why: A meaning change silently invalidates every existing usage while leaving all code compiling and all builds green, so it is the one token change that cannot be detected by any automated check. Encoding it as a major version is the only signal consumers receive.
SHOULD — Give every semantic token a description stating when to use it and when not to, not a restatement of its name.
Why: Token selection is the decision consumers actually make, and it is made from the name alone unless something else is available. Both humans and agents choose the first plausible name, so the difference between fg-muted and fg-subtle has to be written down or it will not be honoured.
Incorrect:
{ "$description": "The muted foreground colour." }
Correct:
{ "$description": "Secondary text that must remain readable; clears 4.5:1 on bg.canvas and bg.surface. For non-essential text use fg.subtle." }
SHOULD — Emit composite tokens such as typography and shadow in decomposed form for CSS consumers as well as in composite form for design tools.
Why: CSS has no syntax for reading one property out of a custom property holding an object, so a composite cannot be partially consumed or partially overridden by a theme without restating the whole value. Emitting both shapes keeps the authoring intent while leaving each property independently addressable.
SHOULD — Record which Design Tokens Format Module version the token source targets, and verify that the generator implements that version before relying on its features.
Why: The format reached its first stable version, 2025.10, as a W3C Community Group report rather than a Recommendation, and generator support lags it — Style Dictionary shipped DTCG support against an earlier editors draft. Features such as JSON Pointer aliases and the Resolver module may parse and be ignored, which fails silently.
Source: Design Tokens Format Module 2025.10, Design Tokens Community Group
SHOULD — Allocate stacking order from a named z-index scale of a handful of layers rather than writing numeric values at usage sites.
Why: Ad-hoc z-index values are chosen relative to whatever was on screen at the time, so they only ever increase, and the resulting numbers encode the order in which features were built rather than the intended layering. A named ladder makes the intended order the thing that is written down.
Incorrect:
.toast { z-index: 9999; }
.modal { z-index: 10000; }
Correct:
.toast { z-index: var(--z-toast); }
.modal { z-index: var(--z-overlay); }
Before reporting completion
Run these checks against your own output. Answer each question explicitly rather than assuming the answer, because the point of the exercise is to notice what you did not notice while building.
Confirm the token tiers are intact and that consumers stay above the primitive layer. (blocking)
- Does any component, page, or utility class reference a primitive token or a raw literal value?
- Does every semantic token resolve through a primitive rather than holding a literal directly?
- How many tokens are in the semantic tier? If it exceeds roughly eighty, which of them are actually component tokens in the wrong place?
- How many component tokens have exactly one consumer and add no new decision?
Confirm names describe roles and scales describe relationships. (blocking)
- Does any semantic token name contain a hue, a value, or an appearance word — blue, purple, grey, light, dark?
- Do all names follow the same general-to-specific segment order?
- For each numeric scale, what is the stated generator, and where is it written down?
- Could a new contributor predict the name of a token that does not yet exist from the names that do?
Confirm there is exactly one hand-edited source and that outputs cannot drift. (blocking)
- Which single file does a human edit to change a token, and is every other artefact generated from it?
- Does CI regenerate the outputs and fail on a non-empty diff?
- Are generated files marked as generated so reviewers do not edit them?
- Does the TypeScript output emit var() references rather than resolved values, so runtime theme switching still applies?
Confirm every reference resolves and no chain is pathological. (blocking)
- Does the build fail on an unresolved reference, or does it emit the raw alias string?
- Is there cycle detection, and does its error message contain the full path?
- What is the longest alias chain in the set, and what does each hop mean?
Confirm every theme defines the same names. (blocking)
- Is the set of semantic token names identical across every theme, mode, and brand? What is the set difference?
- Does any theme introduce a name that exists nowhere else?
- Were the contrast contracts re-checked against each theme independently rather than assumed from the default?
Confirm changes to the token set do not break consumers silently.
- Was any token name removed or renamed without leaving a deprecated alias pointing at its replacement?
- Does every deprecation name the token that replaces it, in machine-readable form?
- Did the meaning of any existing token change while its name stayed the same, and was that released as a major version?
Evaluate the token set against the project Design Contract.
Evaluate the output against the project Design Contract (tokens section).
Run vishwakarma audit if the project has the CLI available.
Further reference
These are not loaded by default. Read one only when its question is the question you currently have.
references/worked-architecture.md— What does a full token set actually look like, end to end — every category, every tier, with real names and real values?references/transformation-pipeline.md— How do I turn one token source into CSS, Tailwind, and TypeScript outputs, and how do I stop the outputs from drifting?