The mistake we made first
Our first dark mode was a find-and-replace: every light surface got a dark twin, every dark text colour got a light one. It shipped, it looked wrong, and nobody could say exactly why.
The reason turned out to be simple. In a light interface, elevation reads as shadow. In a dark one, shadow is invisible — elevation has to read as a lighter surface. We had translated the colours and left the logic behind.
Decide at the token layer
Every colour in the interface should be a token with a role, not a value with a name. surface-raised survives a theme switch. grey-100 does not.
- Name tokens for the job they do: surface, border, body text, muted text.
- Define the dark values next to the light ones, in the same file.
- Never write a raw hex in a component. If a component needs a colour that has no token, the token is what is missing.
Contrast is not symmetrical
Pure white text on a near-black background is harsher than pure black on white — the glow makes it vibrate. We cap dark-mode body text below full white and it reads noticeably calmer at the same measured ratio.
The same holds the other way: a pastel that clears 7:1 against a dark ground can drop under 4.5:1 against a light one. Measure both, every time, before the colour ships.
Test the seams, not the screens
Most dark-mode bugs live where two systems meet: an embedded map, a third-party checkout, a chart library with its own defaults, a PDF preview. Those are the places to look first, and the places a screenshot of the homepage will never show you.
What it costs once you have it
Roughly ten percent more design time per component, and almost nothing per page after the first ten. The doubling only happens when the decision is deferred.
Part of the team building and running the products behind these posts at Hedaya Global Solutions.