Designing a dark mode palette that isn't just inverted
Flipping black and white produces a dark theme that glares, muddies your brand colour and fails contrast in new places. Here is what to do instead.
Why inversion fails
The instinct is to swap the background and text colours and call it done. Three things go wrong.
Pure black is too harsh. White text on #000000 produces a 21:1 ratio, far beyond what anyone needs, and the extreme edge contrast causes halation — text appearing to bleed at the edges, especially for readers with astigmatism. Dark themes are more comfortable on a very dark grey with a hint of the brand hue, somewhere around #12181B to #1A2226.
Saturated colours vibrate. A vivid brand colour that looks confident on white becomes almost fluorescent on near-black, and the eye cannot settle on it. Dark surfaces need colours with the saturation pulled back and the lightness raised.
Elevation reverses. In light mode, a card lifts off the page with a shadow. On a dark background shadows are invisible, so depth has to come from lightness instead — surfaces get lighter as they come forward.
Build the neutral ramp first
Start with four or five surface tones rather than one background and one card colour:
| Layer | Rough lightness | Used for |
|---|---|---|
| Base | 8–12% | Page background |
| Surface | 14–17% | Cards, panels, table rows |
| Raised | 19–23% | Menus, popovers, hovered rows |
| Overlay | 25–30% | Modals, tooltips |
| Border | 28–34% | Dividers and outlines |
Give every one of them the same hue — usually a few degrees of your brand colour — at very low saturation, around 6–12%. A dark theme built on hue-neutral greys looks cheap; the same theme with a consistent 200° tint in the greys reads as designed.
The quickest way to generate the ramp: put your brand colour into Base colour, then open the Tonal scale panel and take the 700, 800 and 900 steps as your surfaces and the 50–200 steps as your text tones. Because the scale is stepped in OKLCH, the dark end stays perceptually even instead of collapsing into indistinguishable near-blacks, which is exactly where an HSL-stepped ramp falls apart.
Then adjust the brand colour
Your light-mode brand colour will almost certainly need a dark-mode sibling. The adjustment is usually:
- Raise lightness by 10–20 percentage points so it reads clearly against a dark surface;
- Lower saturation by 10–20 points so it stops buzzing;
- Leave the hue alone — this is what keeps it recognisably the same brand.
Test the result as a button background with its label on top, and as a link colour in body text. Those two uses fail in opposite directions, and a colour that works for both is usually the right one.
Text: three weights, not two
Pure white body text on a dark surface is as tiring as pure black on white. Use a slightly dimmed white for primary text and step down from there:
- Primary text — around 92–96% lightness. Headings and body copy.
- Secondary text — around 70–75%. Labels, captions, metadata. Check it against every surface it can appear on, not just the base.
- Disabled text — around 45–55%. This will not pass contrast, and that is correct: disabled controls are exempt from WCAG contrast requirements, but make sure the disabled state is also signalled by something other than colour.
Re-check contrast — it does not carry over
A pair that passes in light mode tells you nothing about its dark-mode equivalent. Run every combination through the contrast checker again: primary text on base, primary text on surface, secondary text on surface, link colour on base, button label on the brand colour, and border against its background at 3:1.
Watch for the one that catches everybody: a mid-tone brand colour that had plenty of contrast against white now sits uncomfortably close to a dark grey surface. Raising its lightness fixes it; darkening the surface behind it usually does not fix it enough. Every step in the Tonal scale panel is labelled with the text colours it can carry, which makes picking a safe surface a good deal faster. Full detail in the WCAG contrast guide.
Wiring it up
If you have already built semantic tokens, dark mode is a re-pointing exercise rather than a rewrite:
:root {
--color-base: #FFFFFF;
--color-surface: #F4F8F9;
--color-text: #0F2228;
--color-muted: #5A6B70;
--color-action: #2A93AB;
}
@media (prefers-color-scheme: dark) {
:root {
--color-base: #12181B;
--color-surface: #1A2226;
--color-text: #E8EFF1;
--color-muted: #9BB0B6;
--color-action: #5DC8DE;
}
}
Because components reference the semantic names, nothing below this block needs to change. If you also offer a manual toggle, mirror the same overrides on a [data-theme="dark"] selector so an explicit choice beats the system preference.
Related guides: Colour in a design system · Accessible colour · HEX, RGB, HSL & OKLCH