How to Test Dark Mode on Web (Complete Guide)

Dark mode is no longer a optional visual tweak; it is a user‑expectation that influences accessibility, battery life on OLED screens, and perceived product quality. When a web application fails to ren

March 31, 2026 · 15 min read · How-To Guides

Why Dark Mode Testing Matters

Dark mode is no longer a optional visual tweak; it is a user‑expectation that influences accessibility, battery life on OLED screens, and perceived product quality. When a web application fails to render correctly in dark mode, users encounter unreadable text, invisible controls, or jarring color flashes that break immersion and can lead to abandonment. Beyond aesthetics, dark mode bugs often expose deeper issues in CSS architecture, such as hard‑coded colors, missing CSS custom properties, or reliance on background images that lack inverted variants. These defects surface in production because they are rarely exercised by functional tests that assume a light theme. Consequently, a dedicated dark‑mode test strategy catches regressions early, reduces support tickets, and ensures compliance with WCAG contrast requirements that differ between light and dark palettes.

Test Matrix for Dark Mode

A systematic matrix helps teams verify that every relevant scenario is exercised. Below is a comprehensive table that splits the test space into categories, sub‑scenarios, and expected outcomes. Each row can be mapped to a manual test case or an automated assertion.

CategorySub‑scenarioDescriptionPass Criteria
Happy PathInitial load respects OS preferencePage reads prefers-color-scheme media query and applies dark stylesheet when the OS is set to dark.All colors, backgrounds, borders, and shadows follow the dark palette; no light‑mode colors appear.
Manual toggle via UI controlUser clicks a theme switcher (toggle, button, or settings) to force dark mode irrespective of OS.UI updates instantly; persisted preference (if any) is stored in localStorage or cookie and honored on reload.
Dynamic switch without reloadTheme changes via JavaScript that toggles a class on or without a full page refresh.No flicker; all components react to the class change; animations complete correctly.
Error PathsMissing dark‑mode stylesheetThe dark‑mode CSS file fails to load (404) or is blocked by CSP.Fallback to light mode is visible; console logs a warning; no broken layout.
CSS variable not definedA component uses var(--bg-color) but the dark‑mode scope does not define --bg-color.The element falls back to the browser’s default color; test should flag undefined variable usage.
Inline style overridesInline style attributes set hard‑coded light colors that are not overridden by CSS.Inline styles are either removed or overridden by higher‑specificity dark rules; otherwise test fails.
Edge CasesForced colors mode (Windows High Contrast)OS forces a limited palette; forced-colors: active media query is triggered.All UI remains usable; colors respect the forced palette; no reliance on background images that disappear.
Reduced motion preferenceUser has prefers-reduced-motion: reduce; animations should be disabled or slowed.No disruptive motion; transitions either omitted or respect the reduced‑motion setting.
Print mediaUser prints the page; dark mode should not waste toner.Print stylesheet either forces light mode or uses sufficient contrast for monochrome output.
AccessibilityContrast compliance (WCAG AA)Text and interactive elements meet 4.5:1 contrast against their immediate background in dark mode.Automated contrast checker returns PASS for all foreground/background pairs.
Focus visibilityFocus outlines remain visible against dark backgrounds.Outline color contrasts ≥ 3:1 with surrounding area; outline width ≥ 2px.
Screen reader announcementsLive regions announce theme changes correctly.ARIA role="status" or aria-live announces “Dark mode enabled” when toggled.
Security/PrivacyTheme detection via media queriesMalicious site could infer OS theme to fingerprint users.Ensure no sensitive data is leaked through theme‑dependent behavior (e.g., showing different ads).
Cross‑site theme leakageEmbedded iframe inherits parent theme unintentionally.Iframe respects its own theme or explicitly opts out via color-scheme attribute.

How to Use the Matrix

  1. Map each row to a test artifact – a unit test, an integration test, or a manual checklist item.
  2. Prioritize by risk – contrast and focus visibility often have the highest impact on usability; treat them as blocking.
  3. Automate where possible – use CSS variable assertions, contrast‑checking libraries, and end‑to‑end tests for toggle flows.
  4. Document exemptions – if a third‑party widget cannot be themed, note the limitation and provide a fallback or warning.

Manual Testing Approach

Even with strong automation, manual exploration remains valuable for catching subtle visual regressions, especially when designers introduce new components or when third‑party scripts inject uncontrolled styles.

Tools for Manual Inspection

Step‑by‑Step Procedure

  1. Baseline capture – Open the application in its default state (usually light mode). Screenshot key pages (home, login, product list, checkout) for later comparison.
  2. OS‑level dark mode – Switch the operating system theme to dark. Reload the page (or navigate without reload if SPA). Verify that the UI adopts the dark palette without a full refresh.
  3. Manual toggle – Locate the theme switcher (often in header or user settings). Click it to force dark mode. Observe:
  1. Contrast check – Run the contrast analyzer on each major component. Note any failures; record the exact hex values and compute the contrast ratio manually if needed.
  2. Focus visibility – Tab through interactive elements. Ensure the focus ring is discernible; if the default outline is lost, verify that a custom outline meets contrast requirements.
  3. Reduced motion test – Enable prefers-reduced-motion in the OS settings. Confirm that animations (e.g., modal fade‑in, button hover) are either disabled or replaced with a static transition.
  4. Forced colors simulation – In Chrome DevTools, enable “Force colors” under the Rendering tab. Inspect whether any UI relies solely on background images that disappear; replace with SVG or CSS‑based icons where needed.
  5. Print preview – Open Print Preview (Ctrl+P). Verify that the printed output is legible; if the app forces dark backgrounds for print, adjust the @media print stylesheet to use a light background or sufficient contrast.
  6. Third‑party audit – If the page embeds widgets (e.g., chat, payment iframe), inspect their appearance in dark mode. If they cannot be themed, consider adding a wrapper that forces a light mode via color-scheme: light on the iframe or providing a fallback UI.
  7. Document findings – Log each defect with steps, screenshots, severity, and suggested fix. Tag UI‑related issues for the design system team; tag CSS‑architecture issues for frontend engineers.

Automated Testing Approaches

Automation provides repeatable coverage for regressions and enables continuous integration pipelines to gate merges that break dark mode.

Unit / Integration Tests with CSS Variables

Modern design systems often expose colors through CSS custom properties. A unit test can render a component in a JSDOM environment and assert that the computed style matches the expected dark values.


// Example using Jest and testing-library/react
import { render, screen } from '@testing-library/react';
import { ThemeToggle } from './ThemeToggle';

test('ThemeToggle applies dark mode colors', () => {
  // Simulate OS dark preference
  Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: jest.fn().mockImplementation(query => ({
      matches: query.includes('prefers-color-scheme: dark'),
      media: query,
      onchange: null,
      addListener: jest.fn(), // deprecated
      removeListener: jest.fn(),
    })),
  });

  render(<ThemeToggle />);
  const bg = screen.getByRole('region');
  expect(getComputedStyle(bg).backgroundColor).toBe('rgb(30, 30, 30)'); // #1e1e1e
  expect(getComputedStyle(bg).color).toBe('rgb(220, 220, 220)'); // #dcdcdc
});

Key points:

End‑to‑End Tests with Playwright/Cypress

Playwright offers built‑in support for emulating media features, making it ideal for dark‑mode E2E scenarios.


// playwright.config.js
module.exports = {
  use: {
    // Default viewport, can be overridden per test
    viewport: { width: 1280, height: 720 },
  },
};

 // dark-mode.test.js
const { test, expect } = require('@playwright/test');

test.describe('Dark mode flows', () => {
  test.use({ colorScheme: 'dark' }); // Emulates prefers-color-scheme: dark

  test('home page renders dark colors', async ({ page }) => {
    await page.goto('https://example.com');
    const header = page.locator('header');
    await expect(header).toHaveCSS('background-color', 'rgb(20, 20, 20)');
    await expect(header.locator('h1')).toHaveCSS('color', 'rgb(240, 240, 240)');
  });

  test('theme toggle persists across reload', async ({ page }) => {
    await page.goto('https://example.com/settings');
    await page.click('#theme-toggle'); // assume this toggles dark mode
    await page.reload();
    const body = page.locator('body');
    await expect(body).toHaveCSS('background-color', 'rgb(30, 30, 30)');
  });
});

Cypress achieves similar results via cy.viewport() and cy.matchMedia() or by injecting a tag.


// cypress/integration/dark_mode_spec.js
describe('Dark mode', () => {
  beforeEach(() => {
    cy.visit('/');
    // Force dark mode via CSS
    cy.injectAxe(); // optional for a11y checks
    cy.get('html').should('have.attr', 'color-scheme', 'dark');
  });

  it('maintains contrast on buttons', () => {
    cy.get('button.primary')
      .should('have.css', 'background-color', 'rgb(40, 40, 40)')
      .and('have.css', 'color', 'rgb(250, 250, 250)')
      .then($btn => {
        // manual contrast check using a tiny helper
        const bg = $btn.css('background-color');
        const fg = $btn.css('color');
        const ratio = getContrast(bg, fg); // implement per WCAG formula
        expect(ratio).to.be.atLeast(4.5);
      });
  });
});

Visual Regression Tools

Tools like Percy, Chromatic, or Storybook’s addon-visual-tests capture screenshots of components under both color schemes and compare against a baseline.


// Storybook example with Chromatic
import { ThemeProvider } from './ThemeProvider';
import { Button } from './Button';

export default {
  title: 'Components/Button',
  component: Button,
  parameters: {
    chromatic: { disable: false },
  },
};

export const Dark = () => (
  <ThemeProvider theme="dark">
    <Button label="Primary" />
  </ThemeProvider>
);

When the story runs, Chromatic captures two images (light and dark) and flags any pixel deviation beyond a configured threshold. This catches subtle issues like a border that becomes invisible because its color matches the background in dark mode.

Using SUSA for Autonomous Exploration

SUSA can be pointed at a staging URL or fed an APK‑wrapper for a PWA. Its autonomous agent explores the application using a variety of simulated personas—curious, impatient, elderly, adversarial, etc.—each with distinct interaction patterns (e.g., rapid tapping, prolonged hover, assistive‑technology simulation).

During a dark‑mode campaign, SUSA:

  1. Emulates OS preference – launches Chrome with --force-dark-mode or sets prefers-color-scheme via the DevTools Protocol.
  2. Applies persona‑driven input – the “impatient” persona may spam the theme toggle, while the “elderly” persona uses high‑contrast mode and larger font settings, exposing contrast failures that a scripted test might miss.
  3. Detects UI regressions – compares screenshots against a baseline for each persona, logging any deviation in contrast, layout shift, or missing elements.
  4. Generates regression scripts – after a run, SUSA outputs Appium (Android) or Playwright (Web) test files that reproduce the discovered flows, enabling teams to add those scenarios to their CI pipeline.

Because SUSA does not rely on pre‑written assertions, it can surface issues like a dark‑mode‑specific JavaScript error that only occurs when a user rapidly toggles the theme while a modal is open—a scenario rarely captured in hand‑written test suites.

Concrete Examples

Below are three self‑contained snippets that illustrate common dark‑mode pitfalls and how to verify them.

Example 1: Toggle Switch Implementation

A typical theme switcher stores the user’s choice in localStorage and adds a dark class to the document root.


<!-- index.html -->
<button id="theme-toggle" aria-label="Toggle dark mode">🌙</button>

// theme.js
(() => {
  const STORAGE_KEY = 'theme-preference';
  const root = document.documentElement;

  // Initialize from storage or OS preference
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved) {
    root.classList.toggle('dark', saved === 'dark');
  } else {
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      root.classList.add('dark');
    }
  }

  // Toggle handler
  document.getElementById('theme-toggle').addEventListener('click', () => {
    const isDark = root.classList.toggle('dark');
    localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
  });
})();

Test points

Automated assertion with Playwright:


test('toggle persists', async ({ page }) => {
  await page.goto('/');
  await page.click('#theme-toggle');
  await expect(page.locator('html')).toHaveClass(/dark/);
  await page.reload();
  await expect(page.locator('html')).toHaveClass(/dark/);
});

Example 2: Media Query Preference

A stylesheet uses the prefers-color-scheme media query to define dark variables.


/* variables.css */
:root {
  --bg-color: #fff;
  --text-color: #111;
  --border-color: #ccc;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #111;
    --text-color: #eee;
    --border-color: #444;
  }
}

/* usage */
body {
  background: var(--bg-color);
  color: var(--text-color);
  border: 1px solid var(--border-color);
}

Verification

Automated check with Jest + JSDOM:


test('CSS variables switch with media query', () => {
  const { JSDOM } = require('jsdom');
  const dom = new JSDOM('<!DOCTYPE html><html><head><link rel="stylesheet" href="./variables.css"></head><body></body></html>');
  const { window } = dom;
  // Light
  Object.defineProperty(window, 'matchMedia', {
    value: query => ({ matches: query.includes('dark') ? false : true, media: query }),
  });
  // Force a re‑evaluation by updating the link's media attribute? Simpler: inject a style tag.
  const style = window.document.createElement('style');
  style.textContent = `
    :root { --bg-color: #fff; --text-color: #111; }
    @media (prefers-color-scheme: dark) { :root { --bg-color: #000; --text-color: #fff; } }
  `;
  window.document.head.appendChild(style);
  // Now test
  expect(getComputedStyle(window.document.documentElement).getPropertyValue('--bg-color')).toBe('rgb(255, 255, 255)');
  // Simulate dark
  Object.defineProperty(window, 'matchMedia', {
    value: query => ({ matches: query.includes('dark') ? true : false, media: query }),
  });
  // In JSDOM we need to trigger a media change; easiest is to replace the style tag.
  style.textContent = `
    :root { --bg-color: #000; --text-color: #fff; }
    @media (prefers-color-scheme: dark) { :root { --bg-color: #fff; --text-color: #000; } }
  `;
  expect(getComputedStyle(window.document.documentElement).getPropertyValue('--bg-color')).toBe('rgb(255, 255, 255)'); // still light because media query false
});

*Note*: In real test environments, you would use a tool like css-mediaquery to toggle the media feature directly, but the snippet illustrates the principle.

Example 3: Handling Forced Colors (Windows High Contrast)

When the OS forces a limited palette, authors should avoid relying on background images for essential UI.


/* button.css */
.button {
  background-image: url('icon-light.svg');
  background-repeat: no-repeat;
  background-position: left center;
  padding-left: 2.5rem;
}

/* Forced colors adaptation */
@media (forced-colors: active) {
  .button {
    background-image: none; /* remove SVG that may disappear */
    padding-left: 1rem;
    /* Use system color for text */
    color: CanvasText;
    border: 1px solid CanvasText;
  }
}

Test

Automated with Playwright:


test.for({ colorScheme: 'dark', forcedColors: 'active' })('button remains visible in forced colors', async ({ page }) => {
  await page.goto('/products');
  const btn = page.locator('.button');
  await expect(btn).toBeVisible();
  const box = await btn.boundingBox();
  expect(box.width).toBeGreaterThanOrEqual(44);
  expect(box.height).toBeGreaterThanOrEqual(44);
});

Edge Cases That Only Appear in Production

Even exhaustive test suites can miss issues that manifest only under real‑world conditions: dynamic theming, third‑party code, server‑side rendering, and font loading nuances.

Dynamic Theme Switching via User Settings

Some applications allow users to pick a theme from a settings page, store the choice in a backend, and serve a different CSS bundle on the next request. Problems arise when:

Detection

Third‑Party Widgets

Embedded widgets (maps, chat bots, payment frames) often ship with their own stylesheets that ignore the host’s color scheme. If the widget is embedded via an