Why inversion fails
Inverting a theme — mapping every lightness value L to
1 - L — gets one thing right and four things wrong. It correctly
swaps the background and text. It then turns every brand color into a color
of a different hue, makes every shadow a glow, produces contrast ratios
nobody has checked, and destroys the relationship between elevation and
lightness that the light theme depended on.
The right model is that you have one set of tonal ramps and two sets of role assignments. In light mode, background reads from the 50 end and text from the 900 end. In dark mode, both read from the other end. The ramps do not change; what each role points at does.
Elevation is lightness, not shadow
In a light interface, something raised above the page casts a shadow. In a dark interface a shadow is nearly invisible — there is not enough range below the background for it to fall into. The convention that replaced it is simple: higher means lighter.
| Layer | Light theme | Dark theme |
|---|---|---|
| Page background | #FFFFFF | #0E0E12 |
| Card / surface | #F7F8FA + shadow | #17171E |
| Raised panel, popover | #FFFFFF + larger shadow | #1F1F29 |
| Modal, tooltip | #FFFFFF + largest shadow | #2A2A36 |
Three or four levels is enough. Keep the steps small — 4 to 8 points of OKLCH lightness — because at the dark end of the ramp a small numeric difference is a large perceived one. Shadows still have a job in dark mode, but as a subtle depth cue underneath the lightness change, not instead of it.
Saturated colors vibrate
A highly saturated color against a dark background appears to glow and, at small sizes, to shimmer at its edges. The effect is worst for blues and violets, and it makes body text in a saturated color genuinely tiring to read.
Three adjustments, in order of how often they are needed:
- Move up the ramp. A primary that was 600 in light mode is usually 400 in dark mode: lighter, and because a well-built ramp tapers chroma at the ends, slightly less saturated as a side effect.
- Reduce chroma explicitly for large filled areas. A hero panel at full brand chroma is overwhelming in dark mode in a way it is not in light mode.
- Keep full chroma for small accents. A 2px focus ring or a 16px icon can carry the saturated value; a 400px panel cannot.
Near-black, not black
#000000 with #FFFFFF text is 21:1. That is the
maximum the scale allows and it is more than anyone wants for reading — it
produces halation, the smearing effect where light text bleeds into the dark
around it, which is especially pronounced for readers with astigmatism.
A background around #101014 to #16161C with text
around #E8E8EE gives roughly 15:1, which is comfortable and
still far above AA. It also leaves headroom: pure black has nothing below it,
so there is no color available for a recessed area, an inset field, or a
scrollbar track.
Pure black is a reasonable choice in two cases — OLED battery savings on mobile, and media viewers where the content should be the only lit thing on screen. Even there, the surfaces above the background should not be black.
Contrast behaves differently
The WCAG ratio is symmetric — swapping foreground and background gives the same number — but the perceptual experience is not. Light text on a dark background reads as slightly heavier than the same pair reversed, which is why dark themes often want a lighter font weight than their light counterparts.
Two things fail more often in dark mode than in light mode. Muted text, which designers set by moving toward the background: in light mode that means lighter grey, in dark mode darker grey, and dark grey on near-black runs out of room fast. And borders, which have less range to work with below the surface color than above it — often the answer in dark mode is a border lighter than the surface rather than darker.
Structuring the tokens
Keep primitives theme-independent and let semantic tokens point at different primitives per theme. That way there is one source of color and two mappings, and a component never knows which theme it is in.
:root {
/* primitives — identical in both themes */
--blue-100: #d8e4fb;
--blue-400: #6f9ef0;
--blue-600: #1f5fd0;
--grey-50: #f7f8fa;
--grey-900: #17171e;
--grey-950: #0e0e12;
/* semantics — light theme */
--bg: #ffffff;
--surface: var(--grey-50);
--text: var(--grey-950);
--primary: var(--blue-600);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: var(--grey-950);
--surface: var(--grey-900);
--text: #e8e8ee;
--primary: var(--blue-400);
}
}
:root[data-theme="dark"] { /* same overrides, for an explicit toggle */ }
Two details that save trouble later. Declare the complete light palette on
bare :root so no token exists only inside a media query. And
support all three states — light, dark, and system — because a user who has
explicitly chosen light on a device set to dark expects to get light.
A worked pair
| Role | Light | Ratio | Dark | Ratio |
|---|---|---|---|---|
| Background | #FFFFFF | — | #0E0E12 | — |
| Surface | #F4F5F7 | — | #17171E | — |
| Text on bg | #16181D | 17.6:1 | #E8E8EE | 15.4:1 |
| Muted on surface | #5B6070 | 6.4:1 | #9A9AAE | 6.9:1 |
| Border on surface | #8A90A0 | 3.1:1 | #3A3A48 | 1.6:1 |
| Primary on bg | #1F5FD0 | 5.9:1 | #6F9EF0 | 7.3:1 |
The border row is the interesting one: #3A3A48 on
#17171E is 1.6:1, fine as a decorative divider and
failing 1.4.11 if it is the only thing that makes an input look like
an input. Dark themes need a second, lighter border token for that job —
around #585868 here — and light themes usually do not notice the
distinction because their decorative border is already dark enough.
What goes wrong
- Inverting. See above.
- Pure black plus pure white. 21:1 is glare, not clarity.
- Reusing the light-mode brand color unchanged. It either fails contrast or vibrates.
- Shadows as the only elevation cue. They are nearly invisible on a dark ground.
- One border token. Decorative and functional borders need different values in dark mode.
- Only testing dark mode. The theme people actually use is whichever their OS is set to; both need the same scrutiny.
- Flashing on load. Resolve the theme before first paint, or the page flashes the wrong one.
Checklist
- Dark theme derived from the same ramps, read from the other end — not inverted
- Three or four elevation levels signalled by lightness
- Background near-black rather than pure black, unless OLED is the goal
- Accents moved up the ramp and reduced in chroma for large areas
- Separate decorative and functional border tokens
- Every pairing re-measured for the dark theme
- Semantic tokens remapped; primitives unchanged
- Light, dark and system all supported, with no flash on load
- Focus rings visible against every surface level
Frequently asked questions
Should a dark theme use pure black?
Usually not. Pure black against near-white text is 21:1, which reads as glare rather than as clarity, and on OLED the transition between a black background and a lit surface can smear. A near-black around #121216 keeps the contrast in a comfortable range and leaves room for darker elements below it. Pure black is defensible for OLED battery life and for media viewers where the content should be the only lit thing.
Can I just invert my light theme?
No. Inverting maps your background to your text and vice versa, which is roughly right, but it also inverts every brand color into its complement and produces contrast ratios nobody checked. Dark mode is a second set of role assignments drawn from the same ramps, read from the other end.
Why does my brand color look wrong on a dark background?
Two effects. Saturated colors appear to glow and vibrate against dark backgrounds, and a color that had enough contrast against white often has too little against near-black. The fix is to move up the ramp — a lighter, slightly less saturated step of the same hue — rather than to keep the exact brand value.
Do I need to check contrast separately for dark mode?
Yes. The contrast ratio is a property of a pair of colors, and dark mode is a different pair. Passing in light mode says nothing about dark mode, and in practice the failures cluster in different places: muted text and borders in light mode, saturated accents and status colors in dark mode.