WCAG Contrast Checker for Dark Mode

8 min readUpdated 2026-06-29

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.

Cultural & Historical Context

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

Testing & Standards

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

Real-World Examples

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

Copy-ready dark mode contrast check

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.

Pro Tips

Try It Yourself

Use these free tools to apply what you learned: