Design Tokens
Three tiers, one direction of reference. Every colour, size, radius, shadow and duration in this Bible resolves to a token — and every token resolves to a reason.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
--p-brand-500: #7c6cffRaw values with no meaning attached. A number in a ramp. Never referenced by a component, ever.
--ds-accent: var(--p-brand-500)Meaning, not appearance. This is the only tier a component is allowed to read, and the only tier a theme is allowed to change.
--c-btn-padding-x: 14pxA single component’s private overrides. Must reference tier 2. Exists so one component can deviate without forking the system.
One markup, two themes
Both panels below render identical DOM. The only difference is a data-theme attribute redefining the tier-2 variables. This is the entire payoff of the semantic tier.
A primitive ramp
Tier 1 is boring on purpose. Eleven evenly-spaced steps, no meaning, no opinions — the raw material that semantic tokens are cut from.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
bg: var(--ds-accent)bg: var(--p-brand-500)bg: #7c6cffcalc(var(--radius-xl) - 8px)Every part, every measurement, and the reason it is that number.
/* 1 — PRIMITIVE. A value. No meaning. */
:root {
--p-brand-500: #7c6cff;
}
/* 2 — SEMANTIC. A role. Redefined per theme. */
:root, [data-theme='dark'] {
--ds-accent: var(--p-brand-500);
}
[data-theme='light'] {
--ds-accent: var(--p-brand-600); /* darker: needs 4.5:1 on white */
}
/* 3 — COMPONENT. Private. References tier 2 only. */
.ds-btn--filled {
--c-btn-bg: var(--ds-accent);
background: var(--c-btn-bg);
}The whole architecture in fourteen lines. Note that the light theme does not invert the value — it picks a different step of the same ramp, because contrast requirements differ on white.
- Primitive naming--p-{family}-{step}
Numeric steps, never names like "light" or "dark" — those stop making sense the moment the ramp is extended or the theme flips.
- Semantic naming--ds-{role}[-{modifier}]
Role first: accent, danger, surface, fg. Modifiers are a closed set: -hover, -active, -subtle, -border, -text, -fg.
- Theme scoping[data-theme="light"]
Applied on an element, not only on :root, so a light island can live inside a dark app — which is how the previews in this Bible work.
- Tailwind binding@theme inline
`inline` keeps the var() reference in the generated utility instead of resolving it at build time. Without it, runtime theming silently stops working.
- Component tier--c-{component}-{prop}
Optional and rare. Its purpose is to let one component deviate in a way that is visible and greppable, rather than by adding a magic class.
Every colour in the active theme, resolved from the running stylesheet. Click any swatch to copy its value.
Primitives
--p-*Raw values. Identical in both themes — never reference these from a component.Neutral
--p-neutral-*Brand · Iris
--p-brand-*Success · Emerald
--p-success-*Warning · Amber
--p-warning-*Danger · Rose
--p-danger-*Info · Blue
--p-info-*Categorical
--p-viz-*Semantics
--ds-*What components actually use. Switch the theme and every value below re-reads.Surfaces
Interaction layers
Foreground
Borders
Brand & focus
Success
Warning
Danger
Info
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| Surfaces | ||
| --ds-canvas | — | Page background — the furthest-back surface |
| --ds-surface | — | Default card and panel background |
| --ds-surface-raised | — | A surface sitting above another surface |
| --ds-surface-overlay | — | Dialogs, popovers, menus, toasts |
| --ds-surface-inset | — | Wells: inputs, code blocks, table headers |
| Interaction layers | ||
| --ds-layer-hover | — | Alpha wash applied on hover, composes over anything |
| --ds-layer-selected | — | Persistent selection tint |
| Foreground | ||
| --ds-fg | — | Primary text |
| --ds-fg-secondary | — | Body text, labels |
| --ds-fg-muted | — | Captions, metadata, placeholders |
| --ds-fg-disabled | — | Disabled text — exempt from contrast rules |
| Borders | ||
| --ds-border-subtle | — | Dividers and card edges |
| --ds-border | — | Default component borders |
| --ds-border-strong | — | Checkbox and radio outlines, scrollbars |
| Brand & focus | ||
| --ds-accent | — | Brand actions and selection |
| --ds-focus-ring | — | The single focus outline, tuned to clear 3:1 everywhere |
Spacing
| Token | Value | Used for |
|---|---|---|
| Tailwind scale | gap-*, p-*, m-* — every step is a multiple of 4px |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xs … --radius-3xl | Seven steps, no more |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e0 … --shadow-e5 | — | Six elevation levels |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-display … --text-overline | — | Thirteen named type styles |
Motion
| Token | Value | Used for |
|---|---|---|
| --ease-standard | — | The default curve for almost everything |
| --duration-fast … --duration-deliberate | Seven duration steps |
.card { background: var(--p-neutral-900); }--ds-onboarding-step-3-icon-offset: 3px;@theme { --color-accent: var(--ds-accent); }Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Every foreground/background token pair we ship is verified at 4.5:1 for body text and 3:1 for large text and UI boundaries, in both themes.
- The -text suffix exists precisely for this: --ds-danger-text is the variant certified to sit on --ds-danger-subtle. Using --ds-danger there would fail.
- Contrast is a property of a *pair*, not of a colour. A token audit that checks colours in isolation proves nothing.
- Turn on Inspector Mode and hover any text in this app — it computes the live ratio against the composited background, including alpha layers.
Keyboard
| ⌘I | Toggles Inspector Mode, which reports the tokens behind any element. |
| ⌘K | Opens the command palette to jump between token pages. |
Screen readers
- Tokens are invisible to assistive tech, which is the point: they must never be the only carrier of meaning.
- Any state expressed with a colour token also needs a text or icon equivalent — status badges pair a tone with a dot and a word.
Focus & touch
- One focus token, --ds-focus-ring, used by every component with no exceptions. Users learn what focus looks like exactly once, and a single change fixes it everywhere.
- Spacing tokens are the mechanism for hit-area compliance. The 44px minimum is enforced in the component layer using the same 4px scale, not by ad-hoc padding.
| Attribute | Applied to | Notes |
|---|---|---|
| color-scheme | :root and each theme block | Tells the browser to render native scrollbars, form controls and the caret in the matching scheme. Forgetting it gives you white scrollbars in dark mode. |
| prefers-reduced-motion | Motion tokens | Durations collapse to ~1ms rather than being removed, so transitionend still fires and state machines do not stall. |
| forced-colors | All tokens | In Windows High Contrast Mode the OS overrides colour entirely. Never rely on a token alone to convey state — pair it with an icon, a border or text. |
Example usage
Reading tokens from TypeScript, for canvas rendering, charts and email templates.
1// In JSX, prefer the Tailwind utility bound to the token2<div className="bg-surface text-fg border-line rounded-xl" />34// Or reference the variable directly when the utility does not exist5<div style={{ boxShadow: 'var(--shadow-e3)' }} />67// Reading a resolved value at runtime — charts, canvas, dynamic SVG8function token(name: string) {9 return getComputedStyle(document.documentElement)10 .getPropertyValue(name)11 .trim()12}1314const accent = token('--ds-accent') // "#7c6cff"15const gridLine = token('--ds-border') // "rgb(255 255 255 / 0.11)"1617// Re-read on theme change; the values are not static18const observer = new MutationObserver(() => redrawChart())19observer.observe(document.documentElement, {20 attributes: true,21 attributeFilter: ['data-theme'],22})CSS
Adding a new semantic token. Follow this shape exactly.
/* 1. Add the primitive, only if no existing ramp step works */
:root {
--p-teal-400: #2ed3d3;
--p-teal-600: #12a3a3;
}
/* 2. Add the semantic role to BOTH themes. Never only one. */
:root, [data-theme='dark'] {
--ds-beta: var(--p-teal-400);
--ds-beta-subtle: rgb(46 211 211 / 0.14);
--ds-beta-border: rgb(46 211 211 / 0.34);
--ds-beta-text: var(--p-teal-400); /* 4.5:1 on --ds-beta-subtle */
--ds-beta-fg: #04231a; /* text ON --ds-beta */
}
[data-theme='light'] {
--ds-beta: var(--p-teal-600);
--ds-beta-subtle: rgb(18 163 163 / 0.11);
--ds-beta-border: rgb(18 163 163 / 0.28);
--ds-beta-text: var(--p-teal-600);
--ds-beta-fg: #ffffff;
}
/* 3. Bind it for Tailwind. The "inline" keyword is mandatory. */
@theme inline {
--color-beta: var(--ds-beta);
--color-beta-subtle: var(--ds-beta-subtle);
--color-beta-text: var(--ds-beta-text);
}
/* 4. Verify both pairs before you commit:
--ds-beta-fg on --ds-beta >= 4.5:1
--ds-beta-text on --ds-beta-subtle >= 4.5:1 */Component API
Naming grammar
| Prop | Type | Default | Description |
|---|---|---|---|
| --p-{family}-{step} | primitive | — | Tier 1. Step is 0–1000. No meaning. |
| --ds-{role} | semantic | — | Tier 2. The base value for a role. |
| --ds-{role}-hover | semantic | — | Pointer-over variant of the base. |
| --ds-{role}-active | semantic | — | Pressed variant of the base. |
| --ds-{role}-subtle | semantic | — | Low-alpha fill for backgrounds behind text. |
| --ds-{role}-border | semantic | — | Border pair for the subtle fill. |
| --ds-{role}-text | semantic | — | Text colour certified against the subtle fill. |
| --ds-{role}-fg | semantic | — | Text colour certified against the solid fill. |
| --c-{component}-{prop} | component | — | Tier 3. Private. Must reference tier 2. |
Professional tips
- Write a lint rule that fails any --p-* reference outside the token file. It takes twenty minutes and it is the single highest-leverage thing you can do for a design system.
- When adding a semantic token, add it to every theme in the same commit. A token that exists in one theme resolves to an empty string in the other and silently renders as transparent.
- Alpha values compose; solid values do not. If you are unsure which to pick, ask whether the token will ever sit on more than one background.
- Keep the total count small enough to memorise. Around 80 semantic tokens is the point where people stop guessing correctly and start opening the file.
Performance
- CSS custom properties are resolved at computed-value time and inherit. A variable defined on :root and used in 10,000 elements costs essentially nothing to read.
- Changing a variable on :root does invalidate style for the whole subtree. That is fine for a theme switch; it is not fine to animate a variable on every frame.
- Prefer @theme inline for anything theme-swappable and plain @theme for values that genuinely never change — the latter produces slightly smaller CSS.
- Do not put a var() inside a keyframe you expect to change mid-animation; Chrome and Safari snapshot custom properties at animation start.
Common mistakes
- Defining a token in the dark theme and forgetting the light theme. Nothing errors — the value resolves to an empty string and the element renders transparent.
- Using --ds-danger for text on --ds-danger-subtle. That pair is not certified; --ds-danger-text is.
- Building a "spacing token" per component (--c-card-gap, --c-panel-gap, --c-list-gap) that all equal 16px. That is not a system, it is an indirection tax.
- Forgetting color-scheme, then wondering why native scrollbars, date pickers and autofill backgrounds are white in dark mode.
Real-world recommendations
- Ship tokens as the source of truth in one format (CSS custom properties) and generate everything else — Figma variables, Swift, Kotlin, JSON — from it. Two hand-maintained sources will diverge within a month.
- Version the token file separately from the components. Consumers can then adopt a new palette without a component upgrade.
- Deprecate rather than delete. Keep the old name aliased to the new one for at least one release, and log a console warning in development.
- When a designer asks for "a slightly different blue", the answer is almost always an existing ramp step. Show them the ramp before adding to it — nine times out of ten one of them is the colour they meant.
The checklist for anyone proposing a new token.
Adding to tier 1 is cheap — a ramp step has no opinions. Adding to tier 2 is expensive, because every consumer now has one more thing to learn and one more thing to get wrong. Bias hard toward reuse.