Testing Contrast in Dark Mode Themes
Shipping a dark theme doubles the surface area of every contrast decision, and automated checks rarely notice. A headless browser renders in whichever scheme it happens to prefer, so a scan that reports zero contrast violations may have tested one theme and never seen the other. The result is a familiar bug report: "the dark mode is unreadable on the settings page", from a user who found in a minute what CI missed for months.
This guide covers testing both schemes deliberately — in automation and by hand. It extends Accessible Color Contrast & Theming.
WCAG Success Criteria Addressed:
1.4.3 Contrast (Minimum)1.4.11 Non-text Contrast1.4.1 Use of Color
Prerequisites
- A theme system driven by CSS custom properties, with a
[data-theme="dark"]root or aprefers-color-schememedia query. - A headless browser in CI — Playwright or Puppeteer — with axe-core available.
Emulate the Scheme, Do Not Assume It
The single most important change is to stop letting the environment decide which theme gets tested.
Implementation Guidelines:
- Emulate
prefers-color-schemeexplicitly for every run, and run the whole suite twice — once light, once dark. - If the theme is chosen by an attribute rather than the media query, set that attribute before the scan and assert it took effect.
- Verify the two runs actually differ: compare the computed
background-colorof<body>. Identical values mean the theme never switched and the second run proved nothing. - Run both schemes on the same page set, so a page that exists only in one navigation branch is not silently skipped.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
for (const scheme of ['light', 'dark'] as const) {
test(`no contrast violations in ${scheme}`, async ({ page }) => {
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/settings');
// Prove the theme actually changed before trusting the scan.
const applied = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme'));
expect(applied).toBe(scheme);
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
}
Audit the Token Pairs, Not Just the Pages
Page scans find what is rendered. A token audit finds what could be rendered, including combinations that only appear in states your tests never reach.
Implementation Guidelines:
- Enumerate the legitimate foreground/background token pairs and assert each one's ratio in both themes as a unit test.
- Include non-text pairs: borders, focus rings, chart strokes and icon fills all need 3:1 under
1.4.11. - Fail the build on a pair that regresses, so a designer changing one token cannot silently break five components.
- Keep the pair list next to the token definitions; a list that lives in a test file drifts out of date within a release.
const PAIRS = [
['--text', '--surface', 4.5],
['--muted', '--surface', 4.5],
['--primary', '--surface', 4.5],
['--border', '--surface', 3.0], // non-text: 1.4.11
['--focus', '--surface', 3.0],
] as const;
for (const theme of ['light', 'dark']) {
for (const [fg, bg, min] of PAIRS) {
test(`${fg} on ${bg} in ${theme}`, () => {
expect(contrast(token(fg, theme), token(bg, theme))).toBeGreaterThanOrEqual(min);
});
}
}
Watch for Surfaces That Never Flip
Most dark-mode bugs are not wrong ratios — they are elements that stayed light while everything around them went dark.
Implementation Guidelines:
- Grep for hex literals outside the token definitions; every hit is a candidate for a surface that will not flip.
- Paint inline SVG diagrams from the same custom properties as the rest of the interface, and give each one a full-canvas background rect so floating labels keep their contrast.
- Give iframes and embeds a theme parameter where the provider supports one, and a bordered container where it does not.
- Replace transparent line-art PNGs with SVG using
currentColor, or ship a second asset selected byprefers-color-scheme.
How to Verify
- Two-scheme scan. Run the axe contrast rule in both schemes on the same page set and diff the results. Any page failing in only one scheme is a theming bug, not a palette bug.
- Screenshot diff. Capture both schemes for a sample of pages and review them side by side. Elements that look identical in both are the ones that never flipped.
- Token unit tests. Assert every legitimate pair in both themes, including non-text pairs at 3:1.
- Manual sweep at night. Look at the app in a dark room in dark mode. Pure-white panels are obvious to a human and invisible to a scanner that only measures ratios.
- Forced-colors pass. Enable a high-contrast system theme and confirm the interface still works; it is a third scheme, and it exposes anything conveyed by colour alone.
- Check the transient states. Hover, focus, active, disabled and selected all produce colour pairs the default render never shows, and each needs to clear its threshold in both schemes.
- Re-run after a palette change. A single token adjustment can move a dozen pairs at once, which is exactly the case the unit tests exist to catch before anyone opens the app.
Common Accessibility Mistakes
- Testing whichever scheme the CI machine prefers. Half the interface is never checked, and nobody notices until a user reports it.
- Reusing the light brand colour in dark mode. A mid-tone that passed on white typically lands near 2:1 on a dark surface.
- Inverting semantic colours. Error red becoming cyan removes the meaning the colour was carrying.
- Forgetting non-text contrast. Borders, focus rings and chart strokes need 3:1 too, and they are usually the first things to disappear.
- Assuming a scan proves the theme switched. Assert the applied theme before trusting the result.
- Leaving diagrams and screenshots light. Two large light rectangles on a dark page are the most visible dark-mode failure a site can ship.
- Elevated surfaces stacked on each other. A card on a panel on the page background gives three neutrals that must stay distinguishable; in dark mode they compress towards each other far faster than in light mode, and borders quietly stop reading.
Conclusion
Dark mode is not a palette swap; it is a second complete interface that needs the same verification as the first. Emulate the scheme explicitly and prove it applied, run the contrast scan twice, assert token pairs as unit tests so a palette change cannot regress five components at once, and go hunting for the surfaces that never flip — hard-coded hexes, baked SVG fills, literal gradients, embeds and transparent images. Then look at it with your own eyes in a dark room, because the failure a scanner cannot describe is usually the one users notice first.
Budget the work honestly when planning a dark theme. The palette is a day; verifying it is a week, because every surface, state and third-party surface has to be looked at twice. Teams that skip the verification ship a theme that passes on the marketing page and fails on the settings screen, and the bug reports arrive from exactly the users who chose dark mode because they needed it. Treating the second theme as a first-class deliverable — with its own test run, its own screenshots and its own sign-off — is what stops that outcome.
Frequently Asked Questions
Does axe test both themes automatically? No. axe measures what is rendered in the browser at the moment it runs. Whichever scheme that browser is in is the only one tested, which is why the emulation call and the assertion that the theme changed both matter.
Should dark mode use pure black?
Usually not. A very dark grey around #12151a reduces halation — the smearing effect many readers experience with pure white text on pure black — and gives room for elevated surfaces to be distinguishable. Pure black also leaves nowhere to go for a "lower" surface.
Do the same ratios apply in dark mode? Yes, the thresholds are identical: 4.5:1 for normal text, 3:1 for large text and non-text elements. What changes is which colours reach them, since perceived contrast at the dark end of the range behaves differently from the light end.
How do I handle user-generated colour, like a chart with brand colours per series? Compute contrast at render time and adjust lightness automatically when a supplied colour fails against the current surface. Keep the hue so the series stays recognisable, and pair colour with shape or direct labels so the chart does not depend on colour alone.
Is a "dim" third theme worth it? Only if there is real demand. Every additional theme multiplies the testing surface, and two well-tested themes serve users better than three where one is unverified. If you do add one, add it to the token tests and the two-scheme scan at the same time.
Related guides
- Accessible Color Contrast & Theming — the parent guide on palettes and tokens.
- Accessible Focus Indicators in Design Systems — the ring that has to clear 3:1 in both schemes.
- Catching Color Contrast Failures with axe-core — how the contrast rule works and where it stops.
- Making Inline SVG Icons Accessible in React — currentColor painting so icons follow the theme.