Skip to content

Tokens

Token architecture

Three layers, each answering a different question. The separation exists so that changing how something looks and changing what it means are two different edits.

The three layersPermalink to this section

A value moves left to right: a raw number becomes an intent, and an intent becomes a component's contract.

Hover or focus a row to trace how a raw value reaches a component.

What each layer is forPermalink to this section

The layers are not arbitrary. Each one is the answer to a question the layer below it cannot answer.

Primitive

Raw values. No meaning, no opinion about where they are used.

95 tokens · 11 groups

Semantic

Intent. What a value means, independent of which component uses it.

56 tokens · 9 groups

Component

Contracts. Per-component hooks for targeted overrides.

231 tokens · 10 groups

RulesPermalink to this section

Four rules keep the layers meaningful. Break them and the architecture becomes decoration.

  • Components consume semantic or component tokens only. A component that names brand-600 has hardcoded a brand decision.
  • Semantic tokens reference primitives, never other semantic tokens. Two-step aliasing makes the resolved value impossible to predict.
  • A component token exists when a component genuinely needs independent retheming — not merely because it uses a colour.
  • Every token name says what it is for, not what it looks like. brand-primary survives a rebrand; purple-button does not.

Layer referencePermalink to this section

Where each layer lives, who may read it, and what changing it affects.

LayerExampleConsumed byChanging it affects
Primitive--brand-600Semantic tokens onlyEverything downstream — a rebrand
Semantic--brand-primaryComponents and product codeEvery component expressing that intent
Component--button-primary-backgroundOne componentThat component only

Why not just use the primitive?Permalink to this section

Because the primitive cannot express intent, and intent is what survives a redesign.

background: var(--brand-primary);

Doname the role

The declaration says what the colour is doing. Reassigning the brand updates it automatically.

background: var(--brand-600);

Don'tname the value

Now every consumer has to be found and edited by hand when the brand changes — and some will be missed.

In codePermalink to this section

The layers exist as CSS custom properties. Tailwind's theme maps the semantic layer onto utility classes, so most product code never writes var() at all.

src/tokens/*.ts
// primitives.ts — a raw value, no meaning.
"brand-600": { value: "#c94e0c", description: "Brand base." }

// semantic.ts — what the value is for.
"brand-primary": {
  value: { light: "brand-600", dark: "brand-400" },
  description: "Primary action background.",
}

// components.ts — where it applies.
"button-primary-background": {
  value: "brand-primary",
  description: "Primary variant fill.",
}

Dark modePermalink to this section

Light and dark values are authored together and emitted from the same registry. The saved preference is applied before hydration, so the first painted frame is already correct.

One attribute to switch

Every component reads semantic and component tokens. The theme provider changes data-theme on the root element; no component receives a theme prop and no component file changes.

src/tokens/semantic.ts
background: {
  value: { light: "neutral-0", dark: "neutral-950" },
  description: "The page canvas.",
},
"surface-raised": {
  value: { light: "neutral-0", dark: "neutral-900" },
  description: "Cards and overlays above the canvas.",
},
"brand-primary": {
  value: { light: "brand-600", dark: "brand-500" },
  description: "Primary action background.",
}