core accessibility principles for modern frameworks

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 Contrast
  • 1.4.1 Use of Color
Which token pairs invert cleanly and which need attention Three categories of colour token. Neutral pairs such as text on surface invert cleanly because both ends move together. Brand pairs need a lightened variant in dark mode, because a mid-tone brand colour that passed on white fails on a dark surface. Semantic and data colours such as error red or chart series should stay saturated and be re-tuned individually, since inverting them changes their meaning. Neutral pairs — invert together text on surface, muted on surface, border on background If both ends swap, the ratio is preserved and no re-tuning is needed. Brand colours — need a lighter variant A mid-tone brand blue at 7:1 on white lands near 2:1 on a dark surface. Define a dark-theme value rather than reusing the light one. Semantic and data colours — re-tune, never invert Error red must stay red; a chart series must keep its identity across themes. Adjust lightness only, and keep the hue recognisable.

Prerequisites

  • A theme system driven by CSS custom properties, with a [data-theme="dark"] root or a prefers-color-scheme media 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-scheme explicitly 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-color of <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.

The surfaces most often left behind in a dark theme Five common offenders listed with the reason each is missed. Hard-coded hex values in component CSS never flip because they bypass the tokens. Inline SVG diagrams keep their baked fills. Gradients written as literal colour stops stay light even when the tokens change. Third-party embeds and iframes render in their own scheme. Images with transparent backgrounds assume a white page behind them. Hard-coded hex in a component stylesheet Bypasses the tokens entirely, so nothing about it responds to the theme. Inline SVG diagrams with baked fills Light panels with dark labels, floating on a dark page. Paint from tokens instead. Gradients written as literal colour stops Tokenise the whole gradient, not just the colours around it. Third-party embeds and iframes They render in their own scheme; pass a theme parameter where one exists. Transparent PNGs drawn for a white page Dark line art disappears; ship a second asset or use currentColor SVG.

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 by prefers-color-scheme.
A two-scheme contrast pipeline A pipeline with four stages. Token unit tests run first and are the fastest, asserting every foreground and background pair in both themes. A page scan then runs axe twice per page, once per emulated scheme. A screenshot diff captures both schemes for review. A manual sweep in a dark room finishes the pass, catching the surfaces that have correct ratios but obviously never flipped. Token tests every pair, both themes, milliseconds Page scan ×2 axe per emulated scheme Screenshot diff both schemes side by side Human sweep finds what ratios cannot describe Cheapest first: a token test failing in CI costs seconds, the same bug found by a user costs a release.

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.