A design token system is the layer between your design decisions and your code. It is the difference between a stylesheet where colors, spacing, and border radii are scattered as hard-coded values across hundreds of CSS files, and one where every design decision is named, centralized, and changeable in a single place.
CSS custom properties are the native mechanism for implementing design tokens on the web. This article covers how to structure a token system that scales from a small project to a large application, and how to extend it to support dark mode and other theming scenarios.
The Two Layers of a Token System
A mature token system has two layers: primitive tokens and semantic tokens.
Primitive tokens are raw design values with no opinion about where they are used:
:root {
/* Color scale */
--blue-50: #eff6ff;
--blue-100: #dbeafe;
--blue-500: #3b82f6;
--blue-900: #1e3a8a;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-500: #6b7280;
--gray-900: #111827;
/* Spacing scale */
--space-1: 4px;
--space-2: 8px;
--space-4: 16px;
--space-6: 24px;
--space-10: 40px;
}
Semantic tokens map primitive values to named roles in the UI:
:root {
--color-background: var(--gray-50);
--color-surface: var(--gray-100);
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-500);
--color-accent: var(--blue-500);
--color-accent-hover: var(--blue-900);
--space-component-padding: var(--space-4);
--space-section-gap: var(--space-10);
}
Components reference semantic tokens exclusively:
.button {
background: var(--color-accent);
padding: var(--space-2) var(--space-4);
color: var(--gray-50);
}
.button:hover {
background: var(--color-accent-hover);
}
This two-layer structure is what makes the system maintainable at scale. If the brand's primary blue changes from #3b82f6 to #2563eb, you change one primitive token value. Every semantic token that references --blue-500 updates automatically. Every component that uses --color-accent updates as a consequence. No grep-and-replace across the codebase.
Naming Tokens by Role, Not by Appearance
The most important decision in token naming is to name by semantic role rather than visual appearance.
Bad naming:
--color-blue: #3b82f6;
--color-light-gray: #f3f4f6;
--color-dark-gray: #1a1a1a;
Good naming:
--color-accent: #3b82f6;
--color-background-secondary: #f3f4f6;
--color-text-primary: #1a1a1a;
The bad naming fails in two ways. First, the name becomes misleading when the value changes -- --color-blue now holds #2563eb, which is also blue but the name no longer carries specific information. Second, and more critically, appearance names are meaningless in dark mode. You cannot define --color-light-gray: #0f172a in a dark theme without the name becoming actively wrong. Semantic names survive theme changes because they describe what the value does, not what it looks like.
Extending the Token System for Dark Mode
Once the semantic token layer is in place, dark mode becomes a single override block. Define the dark mode semantic tokens under a [data-theme="dark"] attribute selector that JavaScript toggles on the <html> element:
[data-theme="dark"] {
--color-background: var(--gray-900);
--color-surface: #1e293b;
--color-text-primary: var(--gray-50);
--color-text-secondary: var(--gray-500);
--color-accent: var(--blue-100);
--color-accent-hover: var(--blue-50);
}
Every component in the application now responds to the theme change without any component-level changes. The semantic token layer absorbs the entire theming concern.
You can also use the CSS media feature prefers-color-scheme to apply the dark token set automatically when the user's OS is in dark mode, before any JavaScript runs:
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--gray-900);
--color-surface: #1e293b;
--color-text-primary: var(--gray-50);
--color-text-secondary: var(--gray-500);
--color-accent: var(--blue-100);
}
}
The [data-theme] attribute selector, when present in the DOM, overrides the media query result. This gives you the correct priority order: user's explicit preference from the UI toggle takes precedence over the OS default.
Organizing a Large Token File
For applications with hundreds of tokens, a flat file becomes hard to navigate. Two organizational strategies work well:
Layered file structure: Split tokens into files by category (tokens/colors.css, tokens/spacing.css, tokens/typography.css) and import them in a single tokens/index.css. Each file is focused and manageable.
Comment-delimited sections in a single file: For smaller projects, sections within one file work well:
:root {
/* =====================
COLOR - PRIMITIVES
===================== */
--blue-500: #3b82f6;
/* ... */
/* =====================
COLOR - SEMANTIC
===================== */
--color-accent: var(--blue-500);
/* ... */
/* =====================
SPACING
===================== */
--space-4: 16px;
/* ... */
}
What matters more than the organizational structure is the convention: every color value used in a component stylesheet must have a corresponding entry in the token file. Any hex value that appears in a component stylesheet without a token reference is a gap in the system.
When to Add a New Token vs. Use an Existing One
The question of when to add new tokens versus reusing existing ones is where design token systems often accumulate clutter. A useful rule: add a new semantic token only when the UI has a distinct need that does not already have a named representation.
If a component needs a color that does not fit any existing semantic token, that is a signal that a new semantic category may be needed -- not just a one-off override. If the component is the only place in the application where this distinction matters, consider whether the distinction belongs in the token layer at all or should remain as a component-level style.
Common token categories that teams consistently underestimate when starting out: interactive state tokens (hover, focus, active, disabled), status tokens (success, warning, error, info), and surface hierarchy tokens (background, surface, overlay, tooltip). Building these categories from the beginning avoids the retrofitting work that comes when the first status message or modal is added and there are no matching tokens in the system.
Tooling That Supports Token System Discipline
Token systems accumulate gaps silently without enforcement. A few tools reduce the maintenance burden:
Stylelint with a custom rule that flags hard-coded hex values in component files catches gaps at commit time rather than in a periodic audit. A value like background: #3b82f6 in a component file is a lint error; background: var(--color-accent) is not. This is the highest-leverage automation addition to a token system.
CSS DevTools panel in Chrome and Firefox shows computed custom property values per element, which makes debugging theme application problems significantly faster than inspecting computed styles manually.
Visual regression snapshots of both light and dark mode after any token change catch unintended cascade effects before they reach production.
Validation: Does Your Token System Cover What It Should?
After implementing a token system, a quick audit catches the most common gaps:
- Grep your stylesheets for hex values (
#[0-9a-fA-F]{3,6}). Any hard-coded hex outside the token file is a missing token. - Grep for hard-coded pixel values in spacing properties (
margin,padding,gap) that do not usevar(--space-*). These are gaps in the spacing token layer. - Apply your dark mode toggle and look for elements that remain light. Each one indicates a hard-coded value that bypassed the token system.
"The most common mistake we see in token systems is adding tokens for one-off component needs rather than for genuine semantic distinctions. You end up with fifty color tokens when ten would cover the full design language. Start narrow, expand only when the UI demands it, and keep the token names honest about what they represent." -- Dennis Traina, founder of 137Foundry (view services)
The 137Foundry development team applies this audit at the beginning of any front-end refactor that includes theming work. The grep for hard-coded hex values reliably surfaces a first pass of missing tokens within minutes.
For the practical implementation of dark mode built on top of a CSS custom property token system, How to Add Dark Mode to a Web Application covers the full pattern including localStorage persistence and the flash-of-wrong-theme prevention.
The browser support for CSS custom properties is comprehensive -- caniuse confirms coverage across all modern browsers. This is a technique that is safe to ship without polyfills or build-tool dependencies in any application that does not target Internet Explorer.
Top comments (0)