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-mixfunction 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:
- Shared Language: Designers and developers finally talk about the same things. We don’t talk about “#0077B6”, we talk about
action-primary. - Theming made easy: Want to add a dark mode? You just swap the mapping of your semantic tokens. The components stay exactly the same.
- 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.