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.
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.
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.
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.
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.