Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

The three tiers
1
Primitive--p-*
--p-brand-500: #7c6cff

Raw values with no meaning attached. A number in a ramp. Never referenced by a component, ever.

2
Semantic--ds-*
--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.

3
Component--c-*
--c-btn-padding-x: 14px

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

dark

Same markup

Not one class changed. Only --ds-* moved.

AccentSuccessDanger
light

Same markup

Not one class changed. Only --ds-* moved.

AccentSuccessDanger

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)
CorrectComponent → semantic
bg: var(--p-brand-500)
WrongComponent → primitive
bg: #7c6cff
WrongHard-coded literal
calc(var(--radius-xl) - 8px)
AcceptableDerived with calc()

Anatomy

Every part, every measurement, and the reason it is that number.

css
/* 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.

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

  2. Semantic naming--ds-{role}[-{modifier}]

    Role first: accent, danger, surface, fg. Modifiers are a closed set: -hover, -active, -subtle, -border, -text, -fg.

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

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

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

Color palette

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

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
Tailwind scalegap-*, p-*, m-* — every step is a multiple of 4px

Radius

TokenValueUsed for
--radius-xs … --radius-3xlSeven steps, no more

Shadow

TokenValueUsed for
--shadow-e0 … --shadow-e5—Six elevation levels

Typography

TokenValueUsed for
--text-display … --text-overline—Thirteen named type styles

Motion

TokenValueUsed for
--ease-standard—The default curve for almost everything
--duration-fast … --duration-deliberateSeven duration steps

Do

--ds-danger-subtle--ds-surface-raised--ds-fg-muted
Name by role, not by appearance--ds-danger survives a rebrand from red to orange. --ds-red becomes a lie the day the brand changes, and then someone adds --ds-red-but-actually-orange.
Let the theme choose a different ramp stepLight and dark are not inverses. #7C6CFF passes 4.5:1 on near-black and fails on white; the light theme uses the 600 step instead. The token name stays identical, so no component knows or cares.
hovered row
hovered row
hovered row
Use alpha for interaction layersA hover state defined as an alpha white composes correctly over a card, a table row, a menu item and a coloured banner. A hover state defined as a solid grey works on exactly one of those.
--ds-success--ds-success-subtle--ds-success-border--ds-success-text
Keep the modifier vocabulary closedSix suffixes cover every state we have ever needed: -hover, -active, -subtle, -border, -text, -fg. A closed vocabulary means you can guess a token name correctly without looking it up.

Don't

.card { background: var(--p-neutral-900); }
Do not reference a primitive from a componentIt bypasses the theme layer entirely. The component will look correct in whichever theme you developed it in and subtly wrong in the other, and the bug will be invisible in code review.
--ds-onboarding-step-3-icon-offset: 3px;
Do not create a token for a single usageA token is a shared decision. One with a single call site is just a variable with extra ceremony, and it inflates the surface area everyone else has to learn.
--ds-gray-4--ds-shadow-3-alt--ds-blue-secondary-2
Do not encode numbers into semantic names--ds-gray-4 tells you nothing about when to use it, so people pick by eye and the meaning drifts. Numbers belong in tier 1, where they describe position in a ramp and nothing more.
@theme { --color-accent: var(--ds-accent); }
Do not resolve theme variables at build timeTailwind’s @theme without `inline` bakes the value into the utility. Runtime theming stops working and nothing errors — the light-mode toggle just silently does nothing.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.3Contrast (Minimum)AA1.4.11Non-text ContrastAA1.4.12Text SpacingAA

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

⌘IToggles Inspector Mode, which reports the tokens behind any element.
⌘KOpens 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.
AttributeApplied toNotes
color-scheme:root and each theme blockTells 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-motionMotion tokensDurations collapse to ~1ms rather than being removed, so transitionend still fires and state machines do not stall.
forced-colorsAll tokensIn 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.

Code

Example usage

Reading tokens from TypeScript, for canvas rendering, charts and email templates.

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

css
/* 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

PropTypeDefaultDescription
--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}-hoversemantic—Pointer-over variant of the base.
--ds-{role}-activesemantic—Pressed variant of the base.
--ds-{role}-subtlesemantic—Low-alpha fill for backgrounds behind text.
--ds-{role}-bordersemantic—Border pair for the subtle fill.
--ds-{role}-textsemantic—Text colour certified against the subtle fill.
--ds-{role}-fgsemantic—Text colour certified against the solid fill.
--c-{component}-{prop}component—Tier 3. Private. Must reference tier 2.

Notes

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.

Extending the system

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.