Skip to content

The publication for web craftspeople Wednesday, 19 August 2026

Design & UX

CSS design tokens: the method for a maintainable light/dark theme

Two token layers, a semantic redefinition and three lines of script: the method that prevents drifting dark.css files and cascading !important rules.

Design tokens en CSS : ma méthode pour un thème clair/sombre maintenable

Dark themes bolted on after the fact leave a recognisable trail: a variable here, an !important there, a duplicated dark.css file that drifts away from the light theme sprint after sprint. The problem is not colour, it is the absence of structure. Design tokens expressed as CSS custom properties supply that structure, with no dependency and no JavaScript layer.

A token is not a colour

The most common confusion is treating a raw value (#1e40af) as a token. A design token is a named intent: “the main text colour”, not “a dark blue”. Hence an organisation in two layers. The primitive layer holds raw values, never used directly by components. The semantic layer holds intents, and is the only one selectors touch.

:root {
  /* Layer 1: primitives (never used as-is) */
  --gray-900: #111418;
  --gray-50:  #f7f8fa;
  --blue-500: #2563eb;
  --blue-300: #93c5fd;

  /* Layer 2: semantic (the component API) */
  --color-bg:      var(--gray-50);
  --color-text:    var(--gray-900);
  --color-accent:  var(--blue-500);
}

The maintenance benefit is immediate: a change of brand hue is handled in the primitives, a contrast issue raised in an accessibility audit is handled in the semantic layer. Components do not move — they consume var(--color-text).

A component that references a raw colour is technical debt in disguise. A component that references a semantic token is a contract.

Dark mode as a redefinition

Once that separation is in place, dark mode becomes an unremarkable operation — which is exactly the goal. Nothing is duplicated: only the semantic layer is redefined. Primitives stay, components stay, only the mapping changes.

1. Respect the system preference

The starting point is prefers-color-scheme, which reflects the preference expressed by the user at operating-system level. Ignoring it means overriding an explicit setting.

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg:     var(--gray-900);
    --color-text:   var(--gray-50);
    --color-accent: var(--blue-300);
  }
}

2. Allow an explicit choice

An attribute set on the root element overrides the system preference when the user switches manually. Rule order determines priority: the explicit choice must win in both directions.

:root[data-theme='dark'] { /* same redefinitions */ }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) { /* same redefinitions */ }
}

3. Avoid the flash on load

The only genuinely necessary line of JavaScript is a blocking script in the <head> that applies the stored theme before the first paint. Any other approach produces a light-theme flash on pages loaded in dark mode.

<script>
  try {
    var t = localStorage.getItem('theme');
    if (t) document.documentElement.setAttribute('data-theme', t);
  } catch (e) {}
</script>
Worth noting: declaring color-scheme: light or dark on :root is not a detail. It tells the browser to adapt form controls, scrollbars and native fields. Without it, a dark theme is always given away by its <select> elements.

What the method prevents

Common symptomCauseToken-based answer
A dark.css file that driftsDuplicated rulesOne stylesheet, semantic redefinition
Accumulating !importantSpecificity conflictsComponents read a single variable
Insufficient contrast in dark modeColours chosen case by caseCentralised fix on one token
Light flash on loadTheme applied after paintBlocking script in the <head>

What to take away

A maintainable light/dark theme requires neither a framework nor a JavaScript layer: two token layers, a semantic redefinition, and a three-line script against the initial flash. Most of the work is an architectural decision taken before the first style rule is written.

This is the method I have applied on client projects for several years, and this site’s theme is a direct application of it. The rule I no longer bend: no component references a primitive. The day that rule goes, the debt is back within six months. — Simon Janvier

Also on Mail Studio

Read next