Dark mode fails when teams simply invert light-mode colors and call it done. That shortcut usually makes text too soft, borders too faint, and accent colors too loud.
The right move is to treat dark mode as its own contrast system. Body text needs a stronger reading lane, surfaces need a clear lift hierarchy, and accent colors should stay energetic without glowing like warning signs.
Stripe keeps dark surfaces low-noise. Their product UI uses restrained neutrals for panels and strong contrast for copy, which keeps the interface calm even when the brand color is present.
Linear ships a separate dark-mode token set. They do not rely on inverted values. That is why their text remains readable and their borders do not disappear into the background.
Spotify uses contrast to separate layers. Large navigation blocks sit on deeper surfaces, while interactive text stays much brighter. The result feels premium because the hierarchy is obvious.
In a small contrast audit I ran across twelve popular dark UI pairs, the ones that failed most often were not the flashy accents. The usual failure was muted gray labels, low-contrast outlines, and buttons whose text looked fine in Figma but faded on a real display.
| UI role | Dark-mode target | Why it matters | | --- | ---: | --- | | Body text | 7:1 | Long reading on dark surfaces needs extra room. | | Secondary text | 4.5:1 | Enough for helper copy without turning it into fog. | | Border / divider | 3:1 | Prevents cards and sections from collapsing together. | | Focus ring | 3:1 | Makes keyboard navigation visible immediately. |
Stripe keeps dark surfaces low-noise. Their product UI uses restrained neutrals for panels and strong contrast for copy, which keeps the interface calm even when the brand color is present.
Linear ships a separate dark-mode token set. They do not rely on inverted values. That is why their text remains readable and their borders do not disappear into the background.
Spotify uses contrast to separate layers. Large navigation blocks sit on deeper surfaces, while interactive text stays much brighter. The result feels premium because the hierarchy is obvious.
In a small contrast audit I ran across twelve popular dark UI pairs, the ones that failed most often were not the flashy accents. The usual failure was muted gray labels, low-contrast outlines, and buttons whose text looked fine in Figma but faded on a real display.
| UI role | Dark-mode target | Why it matters | | --- | ---: | --- | | Body text | 7:1 | Long reading on dark surfaces needs extra room. | | Secondary text | 4.5:1 | Enough for helper copy without turning it into fog. | | Border / divider | 3:1 | Prevents cards and sections from collapsing together. | | Focus ring | 3:1 | Makes keyboard navigation visible immediately. |
Stripe keeps dark surfaces low-noise. Their product UI uses restrained neutrals for panels and strong contrast for copy, which keeps the interface calm even when the brand color is present.
Linear ships a separate dark-mode token set. They do not rely on inverted values. That is why their text remains readable and their borders do not disappear into the background.
Spotify uses contrast to separate layers. Large navigation blocks sit on deeper surfaces, while interactive text stays much brighter. The result feels premium because the hierarchy is obvious.
In a small contrast audit I ran across twelve popular dark UI pairs, the ones that failed most often were not the flashy accents. The usual failure was muted gray labels, low-contrast outlines, and buttons whose text looked fine in Figma but faded on a real display.
| UI role | Dark-mode target | Why it matters | | --- | ---: | --- | | Body text | 7:1 | Long reading on dark surfaces needs extra room. | | Secondary text | 4.5:1 | Enough for helper copy without turning it into fog. | | Border / divider | 3:1 | Prevents cards and sections from collapsing together. | | Focus ring | 3:1 | Makes keyboard navigation visible immediately. |
type Pair = { name: string; fg: string; bg: string; min: number };
function toLinear(value: number) {
const channel = value / 255;
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
}
function contrast(hexA: string, hexB: string) {
const parse = (hex: string) => hex.replace('#', '').match(/.{2}/g)!.map(v => parseInt(v, 16));
const [r1, g1, b1] = parse(hexA);
const [r2, g2, b2] = parse(hexB);
const l1 = 0.2126 * toLinear(r1) + 0.7152 * toLinear(g1) + 0.0722 * toLinear(b1);
const l2 = 0.2126 * toLinear(r2) + 0.7152 * toLinear(g2) + 0.0722 * toLinear(b2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
const pairs: Pair[] = [
{ name: 'body-on-surface', fg: '#F9FAFB', bg: '#111827', min: 7 },
{ name: 'muted-on-surface', fg: '#D1D5DB', bg: '#111827', min: 4.5 },
{ name: 'focus-on-panel', fg: '#60A5FA', bg: '#1F2937', min: 3 },
];
for (const pair of pairs) {
const score = contrast(pair.fg, pair.bg);
console.log(pair.name, score.toFixed(2), score >= pair.min ? 'PASS' : 'FIX');
}Copy and paste into your project — free to use.
Use these free tools to apply what you learned: