Design tokens, from primitive to component

A token is a design decision with a name. Primitive, semantic and component tokens each hold a different kind of decision, and each points only at the tier below.

A decision with a name

A design token is a named value that stands for a design decision. --color-text-muted is a decision: secondary copy is this grey. #6B7280 is the current answer to it. Components use the name, and the value is free to change underneath.

Decisions and values change at different speeds. "Buttons use the brand colour" can survive five years. The exact hex will change the first time marketing sees it on a projector. With tokens, the value changes in one file and every button, link and focus ring follows. Without them you're grepping for #2563EB and hoping nobody wrote #2563eb or rgb(37, 99, 235).

Tokens also give design and code one vocabulary. When a designer says "that card should be surface-raised", the engineer knows which variable to reach for, and a linter knows a raw hex has no business being there.

The three tiers

Most mature systems settle on three tiers. Each tier points only at the one below it.

Primitive tokens

The raw palette: every colour, size and duration you're allowed to use, named by what it is, with no opinion about where it goes.

--blue-400: #60A5FA;
--blue-600: #2563EB;
--neutral-0: #FFFFFF;
--neutral-900: #111827;
--space-4: 16px;

You'll have a lot of these. A colour ramp alone is usually 10 to 13 steps per hue. That's fine, because almost nothing in your UI should use them directly.

Semantic tokens

Semantic tokens name a job, not a value. They answer "what is this for?", and they're what components consume.

--color-primary: var(--blue-600);
--color-on-primary: var(--neutral-0);
--color-surface: var(--neutral-0);
--color-text: var(--neutral-900);
--space-inset-md: var(--space-4);

This is the tier that makes theming cheap. Dark mode is the same set of semantic names pointing at different primitives: --color-primary moves to --blue-400, --color-surface to --neutral-900. Components don't change at all. The colour version of this gets its own post: primitive vs semantic colour tokens.

Component tokens

The optional third tier: tokens scoped to one component, pointing at semantics.

--button-bg: var(--color-primary);
--button-fg: var(--color-on-primary);
--button-padding-x: var(--space-inset-md);

They pay off when one component has to diverge from the semantic default in a particular theme or brand, or when you ship a library that other teams restyle. For a single product with one brand they're often ceremony. Start without them, and add them for a component the first time someone needs to change it in isolation.

One chain, end to end

Here's one decision traced through every tier:

TierTokenPoints atResolves to
Primitive--blue-600nothing, it's a value#2563EB
Semantic--color-primary--blue-600#2563EB
Component--button-bg--color-primary#2563EB
Usage.btn { background: var(--button-bg) }--button-bg#2563EB

Now look at where each kind of change happens. A rebrand to teal is one line: repoint --color-primary at a teal ramp. A dark theme that wants a lighter blue repoints only the dark semantic layer. A marketing page that wants a different button background touches only the component tier. Each kind of change has one place to happen.

The rule that keeps it honest: references go down. A primitive never points at anything. A component token that points straight at a primitive, like --button-bg: var(--blue-600), has quietly opted out of theming, because dark mode can't reach it.

Not just colour

Colour gets the attention, but any visual decision you repeat is a token candidate:

CategoryPrimitiveSemantic
Colour--blue-600--color-primary
Spacing--space-4: 16px--space-inset-md
Font size--font-size-300: 1.25rem--text-heading-sm
Radius--radius-2: 8px--radius-control
Shadow--shadow-2--shadow-raised
Motion--duration-200: 200ms--duration-exit
Border--border-width-1: 1px--border-width-control

Typography is usually a composite. Family, size, weight, line height and letter spacing only make sense together, so most tools store a text style as one grouped token rather than five loose values.

Three formats you'll meet

CSS custom properties

The runtime format. Every browser reads them, they cascade, and a theme is just a selector that redefines them:

:root {
  --color-surface: var(--neutral-0);
  --color-text: var(--neutral-900);
}

[data-theme="dark"] {
  --color-surface: var(--neutral-900);
  --color-text: var(--neutral-50);
}

Tailwind

Tailwind v4 reads tokens from CSS through @theme and generates utilities from the namespace. --color-primary becomes bg-primary, text-primary and border-primary; --radius-control becomes rounded-control:

@theme {
  --color-primary: #2563EB;
  --color-surface: #FFFFFF;
  --radius-control: 8px;
}

In Tailwind v3 the same values live under theme.extend in tailwind.config.js.

W3C DTCG JSON

The interchange format. The W3C Design Tokens Community Group published the first stable version of its format, 2025.10, in October 2025. A token is a JSON object with $value and $type, groups nest, and aliases use curly braces:

{
  "blue": {
    "600": {
      "$type": "color",
      "$value": {
        "colorSpace": "srgb",
        "components": [0.145, 0.388, 0.922],
        "hex": "#2563eb"
      }
    }
  },
  "color": {
    "primary": {
      "$type": "color",
      "$value": "{blue.600}",
      "$description": "Main action colour. Pair with color.on-primary."
    }
  }
}

Two details catch people out. The stable spec writes a colour as an object (colour space, components, optional hex) and a dimension as { "value": 16, "unit": "px" }, while plenty of tools still read and write plain strings like "#2563eb" and "16px". Check what both ends of your pipeline expect before you connect them. And $type can sit on a group and be inherited, so you don't need to repeat it on every token.

[!TIP] Pick one source of truth. Either the JSON is the source and a build step (Style Dictionary or similar) generates CSS and Tailwind, or the CSS is the source and JSON is an export. Two hand-edited sources drift within a month.

What not to tokenise

Every token has a cost. It's one more name people have to find, learn and not misuse. Leave these out:

  • One-offs. The 37px offset that lines up an illustration on the pricing page is not a system decision. Keep it in the component, with a comment.
  • Values only one component uses. If only the date picker needs a colour, it's a component detail until a second component asks for it.
  • Layout maths. calc(100vh - 64px) belongs in layout code. Tokenise the 64px header height if other things depend on it; leave the formula.
  • Every opacity from 1% to 100%. Pick the three or four you actually use.
  • Content. Copy, icon choice and image crops aren't tokens. Icon size is.

The test we use: if changing the value should change it everywhere at once, it's a token. If changing it should affect one place, it isn't.

Flash builds the primitive and semantic tiers for you. Each hue becomes a ramp, semantic tokens like surface, text and primary alias ramp steps separately for light and dark, and Export hands you the result as CSS, SCSS, Tailwind or JSON. The export walkthrough covers each option.

The tiers only help if people can guess the names, which is what naming design tokens is about.