Design tokens


Looking back over the years of developing websites, I’ve started to recognize a recurring pattern.

At the beginning of a project, I usually start with the homepage and a fresh new design. I set up a foundation of variables for things like heading sizes, spacing, and text colors.

At first, I try to stay consistent and work only with those variables. Sometimes I even deviate slightly from the original design to avoid creating too many new variables. After the first design review, however, I often end up introducing additional variables to accommodate specific requests from the designer.

By the next review cycle, I occasionally resort to hardcoded values because I can’t find a clean or scalable way to map the existing variables to the design requirements.

When I look back at these projects later on, I notice the same outcome: a growing collection of variables that becomes difficult to manage, alongside scattered hardcoded values that increase complexity. As a result, maintaining the project becomes more time-consuming and error-prone over time.

The solution: Design tokens

The solution is to use design tokens. A single design token is a variable with a code-like like color-primary or button-large-font-size and a value like #ff0000 or 18px. The value can be serveral things like a color, font-size or another design token.

The design tokens are structure as a tree and each layer has its own set of design tokens and each next layer uses the design tokens from the previous layer.

Primitives

We start with the root level, which consists of the base variables we call primitives. This layer is the only layer that contains actual raw values like hex codes or pixel sizes.

For colors, this can look like:

  • color-blue-100: #E6F4FA
  • color-blue-200: #BFE3F2
  • color-blue-300: #8CCCE7
  • color-blue-400: #3FA6D0
  • color-blue-500: #0077B6
  • color-blue-600: #006AA3
  • color-blue-700: #005982
  • color-blue-800: #004866
  • color-blue-900: #003247

For fonts, this can look like:

  • font-size-100: 12px;
  • font-size-200: 14px;
  • font-size-300: 16px;
  • font-size-400: 18px;
  • font-size-500: 20px;
  • font-size-600: 24px;
  • font-size-700: 32px;
  • font-size-800: 40px;
  • font-size-900: 48px;

Pro tip: When defining your colors in CSS, you can use the color-mix function to create a color palette from a single base color. It’s a great way to keep your palette consistent without manually picking every shade. Ideally, though, these are defined by the designer in Figma and exported.

:root {
    --color-blue-500: #0077B6;
    --color-blue-100: color-mix(in srgb, var(--color-blue-500) 10%, white);
    --color-blue-200: color-mix(in srgb, var(--color-blue-500) 25%, white);
    --color-blue-300: color-mix(in srgb, var(--color-blue-500) 40%, white);
    --color-blue-400: color-mix(in srgb, var(--color-blue-500) 70%, white);
    --color-blue-600: color-mix(in srgb, var(--color-blue-500) 85%, black);
    --color-blue-700: color-mix(in srgb, var(--color-blue-500) 70%, black);
    --color-blue-800: color-mix(in srgb, var(--color-blue-500) 55%, black);
    --color-blue-900: color-mix(in srgb, var(--color-blue-500) 40%, black);
}

Semantic Tokens

The problem with using primitives directly in your code is that they lack context. If you see --color-blue-500 in a component, you know it’s blue, but you don’t know why it’s blue. Is it a brand color? An action color? A link color?

Semantic tokens bridge this gap. They map a primitive to a specific intent or context.

  • action-primary-background = color-blue-500;
  • action-primary-text = color-white;
  • text-body-default = color-gray-900;
  • surface-background-brand = color-blue-100;

Now, if the brand color changes from blue to purple, you only have to update the mapping in your semantic layer. Your components don’t care—they just know they should use the “primary action background.”

Component Tokens

Sometimes, even semantic tokens are too broad. You might have a specific button type that needs a very particular color that doesn’t fit the general “action” tokens, or you want to ensure that changing a global semantic token doesn’t accidentally break a specific component.

This is where component tokens come in. They are the most specific layer and are scoped to a single component.

.button-cta {
    --button-cta-bg: var(--action-primary-background);
    --button-cta-text: var(--action-primary-text);
    --button-cta-padding: var(--spacing-400);

    background-color: var(--button-cta-bg);
    color: var(--button-cta-text);
}

By using this 3-layer approach (Primitive → Semantic → Component), you create a highly resilient system.

Modes: dark and light

One of the biggest advantages of this structure is that modes become a mapping problem instead of a component rewrite problem.

Your primitives can still hold the raw palette values:

  • color-gray-0 = #ffffff;
  • color-gray-900 = #111111;
  • color-blue-500 = #0077B6;
  • color-blue-300 = #8CCCE7;

Then your semantic tokens describe intent:

  • surface-page = color-gray-0;
  • text-body-default = color-gray-900;
  • action-primary-background = color-blue-500;

For dark mode, you do not change every button, card, and badge individually. You only change how those semantic tokens map to primitives:

:root {
    --surface-page: var(--color-gray-0);
    --text-body-default: var(--color-gray-900);
    --action-primary-background: var(--color-blue-500);
}

[data-theme="dark"] {
    --surface-page: var(--color-gray-900);
    --text-body-default: var(--color-gray-0);
    --action-primary-background: var(--color-blue-300);
}

Your components remain exactly the same:

.card {
    background: var(--surface-page);
    color: var(--text-body-default);
}

.button-primary {
    background: var(--action-primary-background);
}

That is the real value of modes in a token system. Dark mode and light mode are not separate designs stitched into the codebase. They are two different token maps feeding the same components.

If you want to scale beyond dark and light, the same idea applies to brand themes, campaign pages, seasonal variants, or even client-specific skins. The components should not need to know which mode they are in. They should only consume the semantic tokens they were given.


Why this is a game changer

Since I started using this system, I’ve noticed a few major benefits:

  1. Shared Language: Designers and developers finally talk about the same things. We don’t talk about “#0077B6”, we talk about action-primary.
  2. Theming made easy: Want to add a dark mode? You just swap the mapping of your semantic tokens. The components stay exactly the same.
  3. Maintenance: No more searching for hardcoded values. If the spacing needs to be slightly tighter across the site, you update one primitive, and it ripples through the whole system.

It takes a bit more work to set up initially, but trust me—your future self will thank you when that first “small design tweak” comes in.


Demo

Interactive token map

Primitives feed semantics, semantics feed components

Switch color mode or density and watch the same component resolve different values.

Color mode
Density mode

1. Primitives

Raw values

color-gray-0Base value
#ffffff
color-gray-900Base value
#111111
color-blue-300Base value
#8ccce7
color-blue-500Base value
#0077b6
spacing-300Base value
12px
spacing-400Base value
16px

2. Semantic tokens

Light mapping + comfortable

surface-pageMaps to color-gray-0
#ffffff
text-body-defaultMaps to color-gray-900
#111111
action-primary-backgroundMaps to color-blue-500
#0077b6
action-primary-textMaps to color-gray-0
#ffffff
space-controlMaps to spacing-400
16px

3. Component tokens

Resolved tokens

card-backgroundUses surface-page
#ffffff
card-textUses text-body-default
#111111
button-cta-bgUses action-primary-background
#0077b6
button-cta-textUses action-primary-text
#ffffff
button-cta-paddingUses space-control
16px

Component preview

Same component, different token map

The card and button keep the same API. Only the token inputs change.

Card background: #ffffff

What this shows: primitives define the available raw values, semantic tokens decide what those values mean in each mode, including spacing modes, and component tokens keep implementation details local to the component.


References