Design System · 1 of 25
Design tokens
The one source of truth for every value that can change.
Two layers: primitives and semantics
The distinction that makes theming possible.
A primitive token is a raw value with no opinion about where it is used: blue-600, gray-900, space-4. A semantic token names a purpose and points at a primitive: color-action-bg, color-text-muted, space-inline.
Components only ever reference semantic tokens. That means re-theming is a matter of re-pointing semantics at a different set of primitives, not of editing components. A second brand, a dark mode and a high-contrast mode all fall out of the same mechanism.
Keep the primitive layer out of your application code. If a component can see --blue-600, the abstraction has already leaked.
Examples
Primitive:
--blue-600: #1f4fd8— a value, no meaningSemantic:
--color-action-bg: var(--blue-600)— a decisionComponent:
background: var(--color-action-bg)— no raw values
Rules
Name semantic tokens by purpose, not by appearance:
color-danger, notcolor-red.Never let a semantic token point at a semantic token in another theme file.
Delete a primitive only when no semantic token references it.
{
"primitive": {
"blue": { "600": { "$value": "#1f4fd8", "$type": "color" } },
"gray": { "900": { "$value": "#111827", "$type": "color" } },
"space": { "4": { "$value": "16px", "$type": "dimension" } }
},
"semantic": {
"color": {
"action-bg": { "$value": "{primitive.blue.600}", "$type": "color" },
"text": { "$value": "{primitive.gray.900}", "$type": "color" }
},
"space": {
"inline": { "$value": "{primitive.space.4}", "$type": "dimension" }
}
}
}Naming
A token name is a promise to everyone who reads it later.
Good token names survive a palette change. color-red becomes a lie the moment red is replaced by orange, and every consumer who trusted the name has to audit their code. color-danger keeps meaning whatever it points at.
The convention that works is category → role → variant, most general first: color, surface, border, text, space, font, radius, shadow, duration, easing. Within color, roles are action, text, surface, border, feedback; within a role, variants are primary, subtle, strong, muted.
Keep the set small. A scale with forty greys is not a system, it is a palette with commitment issues. If a designer asks for one more step, the answer is usually that one of the existing steps should be reused.
Rules
Lowercase, hyphenated, no abbreviations:
color-text-muted, notcolTxtMut.Describe the role, never the value:
space-tight, notspace-4px.A token set is small enough to print on one page.
Do not encode the medium in the name:
border-hairlineis fine,border-borderis not.
:root {
/* primitives — raw values */
--blue-600: #1f4fd8;
--gray-900: #111827;
--space-4: 16px;
/* semantics — purpose */
--color-action-bg: var(--blue-600);
--color-text: var(--gray-900);
--space-inline: var(--space-4);
}One source, many platforms
Generate the tokens; never hand-maintain the outputs.
Keep the source of truth as JSON (or the W3C Design Tokens format) and generate everything else from it: CSS custom properties for the web, a TypeScript module for JavaScript and chart libraries, Swift and Kotlin objects for native, and a Figma plugin payload for design.
The generated CSS is committed or served, but it is never edited by hand. Hand-edited generated output is how a design system silently forks into four slightly different colour palettes.
Rules
One JSON source, generated by a script you can run offline.
Generated files are read-only. If you need a change, change the source and regenerate.
The build fails on an unknown token reference.
import StyleDictionary from "style-dictionary";
const sd = new StyleDictionary({
source: ["tokens/tokens.json"],
platforms: {
css: {
transformGroup: "css",
buildPath: "packages/ui/src/",
files: [{ destination: "tokens.css", format: "css/variables" }],
},
ts: {
transformGroup: "js",
buildPath: "packages/ui/src/",
files: [{ destination: "tokens.ts", format: "javascript/es6" }],
},
},
});
await sd.buildAll();Theming and dark mode
A theme is a different mapping, not a different stylesheet.
Because components use semantic tokens, a theme is only a new set of semantic-to-primitive bindings. Switching theme means setting one attribute on the root: data-theme="dark". No component changes, no conditional class names, no duplicated CSS.
Default to the user preference with prefers-color-scheme and let them override it — a manual toggle that wins over the system preference, remembered in localStorage. When you add a theme, re-check contrast in it: a muted grey that reads well on paper can disappear on a dark surface.
Rules
One
data-themeattribute on<html>; no theme-specific classes in components.Dark mode is a token mapping, not a second set of component styles.
Re-run the contrast check for every theme you ship.
Honour
prefers-reduced-motionin the theme, not just the component.
:root {
--color-action-bg: var(--blue-600);
--color-text: var(--gray-900);
}
[data-theme="dark"] {
--color-action-bg: #5b86f5;
--color-text: #f3f4f6;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-action-bg: #5b86f5;
--color-text: #f3f4f6;
}
}Renaming and migrating
Tokens are an API, and APIs have to be versioned.
A token rename breaks every consumer, exactly like renaming an exported function. Treat it the way you would treat any public API: ship the new name, keep the old one as an alias for one major version, then delete it in the next major with a codemod and a migration note.
Resist the urge to rename for tidiness. A rename with no user-visible benefit costs every downstream team a migration and buys the design system a cleaner file. If the old name is genuinely misleading — it says "red" but points at orange — that is worth doing, and worth doing slowly.
Rules
A renamed or removed token is a breaking change: major version, migration guide, codemod.
Alias the old name to the new one for at least one release.
Announce deprecations in the release notes with the date they will be removed.
Track token usage so you know who is affected before you break them.
:root {
/* Deprecated in v3 — use --color-action-bg. Removed in v4. */
--color-primary: var(--color-action-bg);
--color-action-bg: var(--blue-600);
}Checklist
Before you call the token layer done.
Run through this list once per theme and once per platform. It is short on purpose.
No raw values in components
Search the codebase for #, rgb( and px. Outside the token source itself, there should be none.
Every semantic token resolves
Build fails on an unresolved reference; check the generated CSS is complete for every theme.
Contrast holds in every theme
Check text, icons and borders against their actual surfaces, not against the token name.
The set is small
If you cannot explain why a token exists, delete it. Unused tokens are future confusion.