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.

Four levels of dark-mode contrast testing Layers from fastest to slowest: token pair unit tests, axe-core page scans in dark mode, chart palette checks, and screenshot diffs of components in both themes. Four levels of dark-mode contrast testingToken pair teststext and UI token pairs computed in both themes; millisecondsaxe-core in darkpage scans with data-theme="dark" forced; seconds per pageChart palette checksseries colours against the dark plot background at 3:1Screenshot diffscomponents rendered in both themes; review changes
Token tests catch most regressions in milliseconds; screenshots catch the rest.

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.

Light-only CI versus both themes Comparison: light-only CI misses dark-mode regressions; testing both themes catches token, badge and chart failures before release. Light-only CI versus both themes✗ Light theme onlyDark badges ship with light text on light fillChart palette unchecked on dark plotsFocus ring too dark on dark surfacesRegressions found by users✓ Both themesBadge tokens tested in bothPalette checked at 3:1 on both backgroundsFocus token pair testedRegressions found in pull requests

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
Contrast ratios of a token set in dark mode before fixes Bar chart of dark-mode contrast ratios for body text 13, muted text 3.9, overdue badge 2.8, focus ring 2.1 and chart series 3 at 2.2; the last four fall below their thresholds. Contrast ratios of a token set in dark mode before fixesBody textpasses 4.5:1Muted textfails 4.5:1Overdue badgefails 4.5:1Focus ringfails 3:1Chart series 3fails 3:1
Token tests find exactly these failures.

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.

Permalink to "Related"

← Back to Visual Regression & Theme Testing