Testing Dark Mode Contrast in CI
Permalink to "Testing Dark Mode Contrast in CI"Dark themes double the surface that needs contrast testing, and they fail in quieter ways than light themes. A colour that passes on white may fail on near-black; a chart palette tuned for light backgrounds turns muddy; status badges keep their light-theme backgrounds and end up as light text on light fills. Most teams test the light theme thoroughly and the dark theme by eye, once. Automated checks in CI close that gap with little effort, because contrast is one of the few accessibility properties machines measure well.
This guide belongs to visual regression & theme testing.
Spec reference
Permalink to "Spec reference"- SC 1.4.3 Contrast (Minimum) (AA) — 4.5:1 for normal text, 3:1 for large text, in every theme the product offers.
- SC 1.4.11 Non-text Contrast (AA) — 3:1 for UI components (borders, focus indicators) and graphical objects needed to understand content (chart marks).
- SC 1.4.6 Contrast (Enhanced) (AAA) — 7:1, optional.
- Contrast ratio — (L1 + 0.05) / (L2 + 0.05) using relative luminance; the same formula for both themes.
WCAG applies to each presentation the user can choose. A dark theme that fails is a failure even if the light theme passes.
When to use — and when not to
Permalink to "When to use — and when not to"Use token tests whenever the design system defines colour tokens — that is where most contrast regressions originate. Use axe in dark mode for pages and states, because it sees real computed colours, including overrides teams add locally. Use screenshot diffs for components with complex visuals, like charts and badges, where axe cannot judge graphics.
Do not rely on axe alone for charts: it checks text contrast but not the contrast of SVG marks against the plot. Do not test only the default theme in CI because the dark theme “uses the same tokens” — the token values differ, and that is the point.
The misapplication: running axe once with the operating system in light mode, then adding a dark theme months later and never re-running.
Annotated code example
Permalink to "Annotated code example"// tokens.contrast.test.js — token pairs in both themes (SC 1.4.3, 1.4.11)
import { tokens } from '../tokens.json';
import { contrast } from './contrast.js'; // relative luminance ratio
const pairs = [
['text.body', 'surface.page', 4.5], ['text.muted', 'surface.card', 4.5],
['badge.overdue.text', 'badge.overdue.bg', 4.5], ['focus.ring', 'surface.page', 3],
['chart.series.1', 'chart.plot', 3], ['chart.series.2', 'chart.plot', 3],
];
for (const theme of ['light', 'dark']) {
describe(`${theme} theme`, () => {
test.each(pairs)('%s on %s ≥ %d:1', (fg, bg, min) => {
expect(contrast(tokens[theme][fg], tokens[theme][bg])).toBeGreaterThanOrEqual(min);
});
});
}
// e2e: axe in dark mode with Playwright
import AxeBuilder from '@axe-core/playwright';
for (const theme of ['light', 'dark']) {
test(`invoices page passes axe in ${theme}`, async ({ page }) => {
await page.emulateMedia({ colorScheme: theme });
await page.goto('/invoices');
await page.evaluate(t => document.documentElement.setAttribute('data-theme', t), theme);
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
expect(results.violations.filter(v => v.id === 'color-contrast')).toEqual([]);
});
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"Contrast testing is visual, but it affects assistive technology users indirectly: screen magnifier users frequently use dark themes or OS inversion; users with low vision rely on focus indicators that must remain visible in dark mode.
| Check | Light result | Dark result | Common dark-only failure |
|---|---|---|---|
| Body text | 15:1 | 13:1 | — |
| Muted text | 5.2:1 | 3.9:1 | Muted grey too dark on near-black |
| Overdue badge | 6.1:1 | 2.8:1 | Light-theme fill kept in dark |
| Focus ring | 4.0:1 | 2.1:1 | Dark green ring on dark surface |
| Chart series 3 | 3.4:1 | 2.2:1 | Palette not adjusted |
Integration context
Permalink to "Integration context"Token tests belong in the design system pipeline alongside focus ring tokens across themes. Page scans extend setting up axe-core in a GitHub Actions pipeline. Chart palettes for dark backgrounds are covered in dark mode chart palettes, and forced colours in forced-colors screenshots with Playwright.
Gotchas
Permalink to "Gotchas"Transparency. Tokens with alpha (rgba) depend on what is underneath. Test them composited over the actual surface colour, not as raw values.
Theme switching without media emulation. If your app sets data-theme from a stored preference, emulating prefers-color-scheme is not enough; set the attribute too.
Images and SVG. axe cannot evaluate text inside images or SVG graphics reliably; screenshot review or SVG-specific checks are needed for charts and diagrams.
Common mistakes
Permalink to "Common mistakes"- Testing only the light theme in CI.
- Checking text tokens but not focus, border and chart tokens.
- Ignoring alpha compositing.
- Accepting axe’s “incomplete” contrast results without review.
Framework notes
Permalink to "Framework notes"Storybook with @storybook/addon-a11y can run axe per story; add a theme toolbar global and run the test runner twice with each theme. Chromatic and Playwright visual comparisons can capture both themes per story. Style Dictionary or similar token pipelines are the right place for token-pair tests, because they produce the resolved values for each theme.
// Storybook test-runner: run each story in both themes
export default { async preVisit(page, context) { await page.evaluate(t => document.documentElement.dataset.theme = t, process.env.THEME); } };
Worked example
Permalink to "Worked example"A data platform introduced a dark theme by inverting neutral tokens and leaving status tokens unchanged. The first release had amber “Pending” badges with white text at 1.9:1 and a focus ring that disappeared on the dark grid. After adding 40 token-pair tests for both themes, axe scans of eight key pages in dark mode, and a palette check for chart series, CI caught both issues in the next design change — before any user saw them. The token tests run in under a second.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Store the required contrast for each token pair next to the tokens (for example, "pairs": [{ "fg": "badge.overdue.text", "bg": "badge.overdue.bg", "min": 4.5 }]) so tests are generated from data and new tokens cannot be added without a pairing decision.
FAQ
Permalink to "FAQ"Does WCAG require a dark theme?
No. But if you offer one, it must meet the same contrast requirements as the light theme.
Can axe test dark mode?
Yes — it checks computed colours, so whatever theme is applied at scan time is tested. Make sure the dark theme is actually applied.
What about OS-level inversion?
That is outside your control; test your own themes and forced colours mode.
Should chart colours meet 4.5:1?
Marks need 3:1 (non-text contrast); text labels on or near them need 4.5:1.
How many pages should be scanned in dark mode?
The same set as in light mode. Themes share layouts, so scanning both doubles coverage cheaply.
Is APCA relevant?
APCA is a proposed contrast method for future guidelines. WCAG 2.2 conformance uses the ratio formula; you can track APCA alongside it.
Related
Permalink to "Related"- Forced-colors screenshots — the third theme
- Dark mode chart palettes — palettes that pass
- Auditing focus indicator contrast — focus rings in detail