Skip to content
Web engineering6 min read

Design tokens, and building dark mode that was actually designed

Most dark modes are a light design with the colours flipped, which is why they look muddy and why text lands on the wrong background in edge cases. A token system fixes it structurally: components reference meaning rather than colour, and a theme becomes a set of values rather than a set of overrides.

Part ofWeb & Portal Development

The symptom that reveals a missing token layer is a component that looks correct in one theme and wrong in the other — light text on a light panel, an accent that disappears, a border invisible against its background. The cause is almost always a component referencing a colour directly instead of a role.

Primitive and semantic tokens are different things

A token system needs two layers, and collapsing them is the usual mistake. Primitives name colours; semantics name jobs. Components may only reference the second layer.

css
:root {
  /* Layer 1 — primitives. Named by what they ARE. Theme-independent. */
  --ember-400: #e7a63a;
  --ember-500: #d98c1f;
  --ink-50:    #f5f5f4;
  --ink-900:   #101317;

  /* Layer 2 — semantics. Named by what they DO. These are what change
     per theme, and the only layer components are allowed to use. */
  --canvas:    var(--ink-50);
  --fg:        var(--ink-900);
  --accent:    var(--ember-500);
  --accent-fg: #1b1403;      /* text ON the accent, not the accent itself */
  --hairline:  rgb(16 19 23 / 0.09);
}

/* A component never names a colour. It names a role. */
.card {
  background: var(--surface);
  color: var(--fg);
  border: 1px solid var(--hairline);
}

Theming has three states, not two

This is where most implementations break. A visitor can have explicitly chosen light, explicitly chosen dark, or made no choice at all — and the third case is the default, where only the operating-system preference distinguishes them.

css
/* 1. Bare :root defines the COMPLETE light palette. Every token must
      be declared here, even ones a theme later overrides. */
:root { --canvas: #fbfbfa; --fg: #101317; /* ...all of them... */ }

/* 2. No explicit choice: follow the OS. Guarded so an explicit light
      choice still wins over a dark OS. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --canvas: #08090c;
    --fg: #f4f4f3;
  }
}

/* 3. Explicit dark choice wins in the other direction too. */
:root[data-theme="dark"] {
  --canvas: #08090c;
  --fg: #f4f4f3;
}

Dark mode is not inversion

Flipping the lightness of every value produces a dark theme that is technically legible and visibly wrong. Several things behave differently on a dark ground and need deliberate values rather than computed ones.

ElementLight themeDark theme needs
ShadowsConvey elevation effectivelyNearly invisible — use borders or lighter surfaces instead
Saturated coloursRead as intendedAppear to vibrate; reduce saturation
Pure white textn/aToo harsh on near-black; use a slightly dimmed value
Pure black backgroundn/aCauses smearing on OLED; prefer a very dark grey
Accent as textOften needs darkening for contrastOften needs lightening
BordersDark, low opacityLight, low opacity — not the same value inverted

That second-to-last row is worth dwelling on. An accent that passes contrast as a fill will frequently fail as text on the same theme's background, which is why an accent-as-text token separate from accent-as-fill is usually necessary rather than fussy.

Test contrast as a build step

Token values drift. Someone adjusts a neutral to look better against a screenshot and quietly breaks a contrast ratio nobody rechecks. The fix is to assert the ratios rather than trust them.

ts
// Parse the real token values out of the stylesheet, so the test
// measures what ships rather than a duplicated copy of the palette.
const pairs: [string, string, number][] = [
  ["--fg",          "--canvas",  4.5],  // body text
  ["--fg-muted",    "--surface", 4.5],  // secondary text
  ["--accent-fg",   "--accent",  4.5],  // label on a filled button
  ["--ring",        "--canvas",  3.0],  // focus indicator, SC 1.4.11
];

for (const theme of ["light", "dark"]) {
  for (const [fg, bg, min] of pairs) {
    expect(contrast(token(theme, fg), token(theme, bg))).toBeGreaterThanOrEqual(min);
  }
}

Note the focus-ring row uses 3:1 rather than 4.5:1 — non-text contrast has a different threshold. Encoding that in the test is what stops someone applying the wrong standard from memory.

Tokens are not only colour

  • Spacing — a fixed scale prevents the accumulation of arbitrary 13px and 17px gaps
  • Type — sizes with paired line heights and letter spacing, so a heading is one token not three decisions
  • Radius — a small set, applied by role, rather than one value stamped on everything
  • Motion — durations and easing curves, so animations across the product feel like one system
  • Layout — container widths and breakpoints referenced by name

Persisting the choice without a flash

A theme read from storage in a React effect applies after first paint, producing a visible flash of the wrong theme. The class must be set before the browser paints, which means a small blocking script in the document head — one of the few legitimate uses for one.

Server-rendered pages need a second consideration: the server cannot know the stored preference, so any UI reflecting the current theme must not assert a value until after hydration, or React will report a mismatch. Render the control immediately and its selected state only once hydrated.

Questions

Why does my dark mode look muddy?

Almost always because it was derived by inverting lightness rather than designed. Shadows stop conveying elevation on a dark ground, saturated colours appear to vibrate, and pure white text on pure black is harsh. Each of those needs a deliberately chosen dark value, not a computed inversion of the light one.

Should components reference primitive or semantic tokens?

Semantic only. A component that names --ember-500 cannot be rethemed; one that names --accent follows every theme automatically. Primitives exist so semantics have somewhere to point — treat them as internal to the token layer and out of bounds for component CSS.

How do I avoid a flash of the wrong theme?

Set the theme class before first paint with a small blocking script in the head, rather than in a client-side effect. An effect runs after paint, so the wrong theme is briefly visible. On server-rendered pages, also delay showing which option is selected until after hydration, since the server cannot know the stored preference.

Is one accent token enough?

Rarely. An accent that passes contrast as a button fill will often fail as text on the same background, so you generally need the fill, the foreground that sits on it, and a separate darker or lighter variant for accent-coloured text. Defining them explicitly is what prevents each component guessing.

Want this applied to your systems?

Most of what is described here begins as an assessment: a fixed-scope review producing a prioritised written report you can act on with any supplier.

Prefer email? hello@kornspire.com