Back to Blog

Dark Mode Visual Testing: How to Test Multi-Theme UIs Without Doubling Your Suite

The night a team ships dark mode is also the night their visual coverage halves. Every component, every page, every modal - there are two of them now, and only one is being snapshotted. Worse: most dark-mode bugs are not "the whole page is wrong." They are subtle. A single token forgot to swap. A stroke stayed at #000 instead of currentColor. A focus ring vanishes against a dark background.

This guide shows the structure that makes multi-theme visual testing tractable: a theme matrix that scales sub-linearly with cost, per-theme baselines that share the same diff config, and a workflow that catches the bugs that matter without exploding CI time.

Theme comparison · /dashboard light dark diff
The same dashboard, light vs. dark. A focus ring that survives on light disappears on dark - flagged as a real diff.

Why naïve doubling is wrong

The first instinct when adding dark mode is to fork every existing test: navbar.light.spec and navbar.dark.spec. CI run time doubles. Maintenance doubles. False positives also double, because every flake now happens twice. After a sprint of this, teams quietly delete the dark-mode tests.

Two-column comparison: forking every spec into light and dark doubles CI time, maintenance, and flakes until tests get deleted, while a theme-agnostic matrix produces N baselines from one test with shared diff config and smart-run CI
Forking specs doubles cost and dies; a theme matrix scales sub-linearly from a single test definition.

The right model is a theme matrix: tests are theme-agnostic; the runner enumerates themes per test. A single navbar test produces N baselines for N themes, sharing diff config, masks, and stabilization.

Setting up the matrix

Tell Lastest which themes exist and how to switch them.

# lastest.config.yaml
themes:
  - name: light
    setup: |
      document.documentElement.dataset.theme = 'light'
  - name: dark
    setup: |
      document.documentElement.dataset.theme = 'dark'
  - name: highContrast
    setup: |
      document.documentElement.dataset.theme = 'hc'
applyToAll: true

Now every test in the suite runs three times. The runner waits for the theme attribute to apply, repaints, and screenshots. You get one test definition and three baselines.

Three switching strategies - and which to pick

  • CSS variables on a root selector. Best for visual tests. Switching is synchronous, no remount, no re-fetch. Tests stay fast.
  • Class on <html> / <body>. Almost as good. Watch for libraries that read the class on mount and never re-read it.
  • System prefers-color-scheme. Hardest to test. The runner has to override the media query at the browser level - Lastest does this with page.emulateMedia(), but it is slower than DOM-only switching.
  • Layered ranking of three theme-switching strategies from fastest to slowest to test: CSS variables on a root selector (synchronous, no remount), a class on html or body (watch mount caching), and prefers-color-scheme (emulateMedia, slower)
    How you switch themes determines how fast your visual tests run, with CSS variables on the root being the cheapest to snapshot.

Use perceptual diffs for theme tests

Dark UIs are antialiasing-noisy. A pixel diff will flag every soft shadow that re-renders 1 px differently across runs. Configure dark-theme baselines to use the perceptual engine and reserve pixel-perfect for icons and design-system swatches.

The bugs you actually catch

  • Hardcoded colors. color: #111 looks fine on light, vanishes on dark.
  • Missing focus rings. Outline contrast that works on white is invisible on near-black.
  • SVG icons with baked fills. Forgot to use currentColor.
  • Disabled states. The 50%-opacity trick fails when the underlying surface changes.
  • Borders. A 1-px border-color: rgba(0,0,0,0.1) disappears on dark.
Theme × viewport coverage 375 px 768 px 1280 px 1920 px light dark high contrast 42 / 42 pass 42 / 42 pass 42 / 42 pass 42 / 42 pass 40 / 42 · 2 review 42 / 42 pass 42 / 42 pass 42 / 42 pass 42 / 42 pass 42 / 42 pass 42 / 42 pass 42 / 42 pass
Theme × viewport matrix. The two bugs cluster on dark mobile - exactly where you would expect dark-mode-only regressions to surface.

Smart selection keeps CI fast

You do not need every test on every theme on every PR. Lastest's impact analysis maps changed files to affected stories. A change to Button.tsx runs Button on N themes; a change to tokens.css runs everything. Most PRs trigger far less than the full matrix, so total CI time stays low even as your theme count grows.

Two themes is the dangerous moment. Three is when teams give up unless the suite is structured. Build the matrix once, share baselines per test, and theming becomes a feature you actually ship instead of a feature you regret.