Custom Properties as a Dark-Light Token System: a Three-Tier Token Architecture
AI generated
{ }
@
CSS · Design Tokens · Dark Mode · Design Systems
Custom Properties as a Dark-Light Token System
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.

20 min read Primitive · semantic · component tokens · light-dark() Design systems · governance

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.

11. FAQ: Custom Properties as a Dark-Light Token System

1Why isn't one layer enough?
One layer mixes color value and meaning. Changes then have to be tracked everywhere instead of centrally.
2What distinguishes the three tiers?
Primitive: raw values. Semantic: meaning with switching. Component: encapsulation per component.
3What does light-dark() do?
Light and dark value in one declaration, instead of a separate media query. Requires color-scheme on root.
4How many tokens at the start?
As few as possible, growing organically by need, not modeled fully upfront.
5Why component tokens?
For variants without rule duplication, only the local token gets overridden per variant.
6Prevent sprawl?
Naming convention per tier plus a documented, reviewed token list before any new token.
7Style Dictionary's role?
Generates custom properties from a platform independent definition, keeps design and code in sync.
8How to test regressions?
Snapshot tests in both schemes, comparing computed values after primitive token changes.
9Is color-scheme required?
Yes, without color-scheme: light dark on root, light-dark() does not work as expected.
10Worth it for small projects?
Not always, a flat list is often enough. Benefit shows up with multiple themes.