A three-tier token architecture with light-dark()
A maintainable dark-light token system does not emerge from a single layer of custom properties, but from three cleanly separated tiers: primitive tokens, semantic tokens and component tokens. Together with the light-dark() function, this architecture can be implemented without a growing media query per component.
Table of Contents
- 1. Why a single layer of custom properties is not enough
- 2. The three-tier architecture: primitive, semantic, component
- 3. The light-dark function: switching without duplicated media queries
- 4. Building a token system in practice
- 5. Component tokens: encapsulation per component
- 6. Governance: naming conventions against token sprawl
- 7. Integration with design tools and Style Dictionary
- 8. Testing and documenting the token system
- 9. Token system in direct comparison to alternatives
- 10. Summary
- 11. FAQ
1. Why a single layer of custom properties is not enough
Many projects start with a single flat list of custom properties, for example --blue: #2563eb or --red: #dc2626. This works for small projects, but quickly becomes a problem the moment a dark-light token system needs to exist. If --blue gets referenced directly in a button component and the meaning of blue should change in dark mode, for example to a lighter shade for sufficient contrast, that change needs to be tracked down everywhere --blue is referenced directly.
The actual problem is mixing two different concepts inside a single variable: the concrete color value and its meaning in the interface. A robust dark-light token system deliberately separates these two layers, by distinguishing between the pure color definition and its semantic usage. This separation is the core of every scalable token architecture and gets built up step by step in the following sections.
2. The three-tier architecture: primitive, semantic, component
A mature dark-light token system consists of three clearly separated tiers. The first tier, primitive tokens, contains only raw color values with no meaning attached, for example --violet-500: oklch(58% 0.19 291). This tier almost never changes and forms the raw palette that every other tier draws from.
The second tier, semantic tokens, gives the primitives a meaning in the context of the application, for example --color-accent or --color-danger. This is exactly where the dark-light switch happens: --color-accent points to a darker primitive shade in light mode and a lighter one in dark mode, so contrast stays correct in both modes. The third tier, component tokens, in turn references the semantic tokens and encapsulates component specific details, for example --button-primary-bg: var(--color-accent).
/* Layer 1: Primitive Tokens — raw values, no meaning attached */
:root {
--violet-100: oklch(0.94 0.05 291);
--violet-500: oklch(0.58 0.19 291);
--violet-900: oklch(0.24 0.10 291);
--slate-50: oklch(0.98 0.01 258);
--slate-900: oklch(0.20 0.02 258);
}
/* Layer 2: Semantic Tokens — meaning, switches with the color scheme */
:root {
--color-accent: var(--violet-500);
--color-surface: var(--slate-50);
--color-on-surface: var(--slate-900);
}
@media (prefers-color-scheme: dark) {
:root {
--color-accent: var(--violet-100);
--color-surface: var(--slate-900);
--color-on-surface: var(--slate-50);
}
}
/* Layer 3: Component Tokens — encapsulate per-component usage */
.button--primary {
--button-bg: var(--color-accent);
--button-fg: var(--color-on-surface);
background: var(--button-bg);
color: var(--button-fg);
}
3. The light-dark function: switching without duplicated media queries
The native CSS function light-dark() significantly simplifies the semantic token tier. Instead of overriding every semantic token in a separate prefers-color-scheme media query, the light and dark value can be specified directly in the definition: --color-accent: light-dark(var(--violet-900), var(--violet-100));. This requires that color-scheme: light dark; is set on the root element, so the browser knows which mode to currently apply.
The benefit for a dark-light token system with many semantic tokens is substantial: instead of maintaining a growing media query with dozens of overrides, every token sits in a single place with both values side by side, which simplifies code reviews and noticeably reduces copy paste mistakes between light and dark values, because both values are visible in the same glance.
/* light-dark() collapses two declarations into one, side by side */
:root {
color-scheme: light dark;
--color-accent: light-dark(var(--violet-900), var(--violet-100));
--color-surface: light-dark(var(--slate-50), var(--slate-900));
--color-on-surface: light-dark(var(--slate-900), var(--slate-50));
--color-border: light-dark(oklch(0.9 0.01 258), oklch(0.35 0.02 258));
}
/* No separate @media block needed for the semantic layer anymore */
.card {
background: var(--color-surface);
color: var(--color-on-surface);
border: 1px solid var(--color-border);
}
4. Building a token system in practice
When building a real dark-light token system, it pays off to start with a small, manageable number of semantic tokens instead of modeling every conceivable variant right away. Typical base categories are surface colors (surface), text colors (on-surface, on-accent), status colors (success, warning, danger) and border colors (border, divider). Each of these categories gets exactly one set of semantic tokens that cover both color schemes through light-dark().
A common mistake when building this out is introducing too many shade steps too early, for example --color-surface-1 through --color-surface-9, before an actual need for so many steps has even appeared. A dark-light token system grows most healthily organically, meaning new tokens only get added once a concrete component actually needs them, instead of designing a complete but largely unused token library upfront.
5. Component tokens: encapsulation per component
The third tier of a dark-light token system, component tokens, exists to make components independent of changes at the semantic tier. Instead of a button component referencing var(--color-accent) directly, it first defines its own, locally scoped tokens like --button-bg and --button-fg, which initially point to semantic tokens but can be overridden per instance.
This extra layer of indirection pays off especially for variants: a .button--danger only overrides --button-bg with var(--color-danger), without duplicating the underlying rule for padding, border radius or typography. Component tokens are what makes a dark-light token system truly reusable across many component variants, without every variant needing its own complete CSS rule.
/* Component tokens: local indirection, overridable per variant */
.button {
--button-bg: var(--color-accent);
--button-fg: var(--color-on-surface);
background: var(--button-bg);
color: var(--button-fg);
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
}
.button--danger {
--button-bg: var(--color-danger);
--button-fg: var(--color-on-danger);
}
.button--ghost {
--button-bg: transparent;
--button-fg: var(--color-accent);
}
6. Governance: naming conventions against token sprawl
Without a clear naming convention, a dark-light token system quickly turns into a confusing collection of inconsistently named variables. A prefix scheme by tier has proven effective: --{tier}-{category}-{variant}, for example --color-surface-raised for an elevated surface shade, or --color-border-focus for a focus border. This consistency makes it easy for new team members to find existing tokens, instead of accidentally creating duplicates with a slightly different name.
Another governance building block is a documented list of allowed semantic tokens that new pull requests get checked against. If a new color value is needed, it first needs to be clarified whether an existing semantic token can be reused, before a new one gets created. This discipline prevents a dark-light token system from growing over time into hundreds of barely distinguishable shades that effectively nobody keeps track of anymore.
7. Integration with design tools and Style Dictionary
In larger organizations, design tokens often first live in Figma or a similar design tool and need to be transferred from there into CSS custom properties. Tools like Style Dictionary read a platform independent token definition, usually as JSON, and automatically generate the matching CSS files from it, including all three tiers of a dark-light token system. This ensures design and code never drift apart, because both get generated from the same source.
What matters in this integration is mapping the three tier structure into the Style Dictionary schema as well, instead of mixing primitive and semantic tokens into a single flat list. Only this way does the transformation between the design tool and the generated CSS stay traceable, and a dark-light token system can keep evolving consistently even under automated generation.
8. Testing and documenting the token system
A dark-light token system should be documented in a Storybook or a comparable style guide environment, with a visual overview of every semantic token in both color schemes side by side. This overview makes it immediately visible when a token accidentally keeps the same value in dark mode as in light mode even though an adjustment would have been needed, or when a newly added token violates the established naming convention.
Automated snapshot tests that render components once in light mode and once in dark mode and compare the computed color values reliably catch regressions whenever a primitive token changes and that change unexpectedly propagates through all three tiers of the dark-light token system. Such tests are especially valuable during larger refactors of the primitive tier, which can theoretically affect the whole system.
9. Token system in direct comparison to alternatives
The following table compares three common approaches to color management in larger projects.
| Criterion | Flat custom properties | Three-tier token system | Sass variables |
|---|---|---|---|
| Runtime dark mode | Limited | Fully supported | Not possible |
| Scalability | Low | High | Medium |
| Component isolation | Missing | Yes, via component tokens | Manual |
| Design tool integration | Cumbersome | Good, with Style Dictionary | Cumbersome |
| Setup effort | Low | Medium | Medium |
For small projects, a flat list of custom properties is entirely sufficient. But as soon as multiple themes, many component variants, or an integration with a design tool come into play, the somewhat higher setup effort of a three-tier dark-light token system pays off through noticeably lower maintenance effort over the lifetime of the project.
Mironsoft
Design token architecture, design system consulting and frontend governance
A token system that grows with your project?
We build three-tier design token systems with custom properties and light-dark(), including naming conventions, a Style Dictionary connection and documentation that makes new team members productive immediately.
Token architecture
Building primitive, semantic and component tokens cleanly separated
Governance
Naming conventions and review processes against token sprawl
Design tool integration
A Style Dictionary pipeline between Figma and CSS custom properties
10. Summary
A durable dark-light token system emerges from three cleanly separated tiers: primitive tokens as pure color values, semantic tokens for meaning within the interface, and component tokens for encapsulation per component. The native light-dark() function significantly simplifies the semantic tier, because light and dark values sit side by side in a single declaration, instead of being spread across a growing media query.
Governance through clear naming conventions and a documented token list prevents a dark-light token system from becoming confusing over time, while an integration with Style Dictionary keeps design and code in sync. Combining these building blocks results in a system that gets more robust with every new component, instead of more fragile with every new color.
Custom Properties as a Dark-Light Token System — The Essentials at a Glance
Three tiers
Primitive tokens for raw values, semantic tokens for meaning, component tokens for encapsulation.
light-dark() function
Both color schemes in one declaration, no growing media query set needed.
Governance
A clear naming convention and a documented token list against sprawl.
Tooling
Style Dictionary keeps the design tool and CSS custom properties in sync.