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
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.
| Category | Sub‑scenario | Description | Pass Criteria |
|---|---|---|---|
| Happy Path | Initial load respects OS preference | Page 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 control | User 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 reload | Theme 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 Paths | Missing dark‑mode stylesheet | The 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 defined | A 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 overrides | Inline 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 Cases | Forced 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 preference | User has prefers-reduced-motion: reduce; animations should be disabled or slowed. | No disruptive motion; transitions either omitted or respect the reduced‑motion setting. | |
| Print media | User prints the page; dark mode should not waste toner. | Print stylesheet either forces light mode or uses sufficient contrast for monochrome output. | |
| Accessibility | Contrast 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 visibility | Focus outlines remain visible against dark backgrounds. | Outline color contrasts ≥ 3:1 with surrounding area; outline width ≥ 2px. | |
| Screen reader announcements | Live regions announce theme changes correctly. | ARIA role="status" or aria-live announces “Dark mode enabled” when toggled. | |
| Security/Privacy | Theme detection via media queries | Malicious 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 leakage | Embedded iframe inherits parent theme unintentionally. | Iframe respects its own theme or explicitly opts out via color-scheme attribute. |
How to Use the Matrix
- Map each row to a test artifact – a unit test, an integration test, or a manual checklist item.
- Prioritize by risk – contrast and focus visibility often have the highest impact on usability; treat them as blocking.
- Automate where possible – use CSS variable assertions, contrast‑checking libraries, and end‑to‑end tests for toggle flows.
- 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
- Browser DevTools – toggle
prefers-color-schemevia the Rendering tab (Chrome) or the Accessibility panel (Firefox). - Theme switcher extensions – such as “Dark Reader” or “Night Eye” to force dark mode on any site, useful for checking fallback behavior.
- Contrast analyzers – the built‑in Chrome Lighthouse audit or the standalone “Colour Contrast Analyser” (CCA) from The Paciello Group.
- Screen‑reader tools – NVDA or VoiceOver to verify announcements and focus order.
- Accessibility bookmarklets – e.g., “aXe” or “WAVE” to highlight contrast failures instantly.
Step‑by‑Step Procedure
- Baseline capture – Open the application in its default state (usually light mode). Screenshot key pages (home, login, product list, checkout) for later comparison.
- 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.
- Manual toggle – Locate the theme switcher (often in header or user settings). Click it to force dark mode. Observe:
- Immediate change of backgrounds, text, borders, icons.
- No layout shift caused by differing dimensions (e.g., dark‑mode borders adding 1px).
- Persistence: close the browser, reopen, and confirm the choice is retained if the app stores it.
- 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.
- 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.
- Reduced motion test – Enable
prefers-reduced-motionin the OS settings. Confirm that animations (e.g., modal fade‑in, button hover) are either disabled or replaced with a static transition. - 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.
- 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 printstylesheet to use a light background or sufficient contrast. - 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: lighton the iframe or providing a fallback UI. - 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:
- Mock
matchMediato emulate the OS preference. - Use
getComputedStyleto read the actual resolved values, ensuring CSS cascade and variable substitution are correct. - Run the same test with a light‑mode mock to guarantee symmetry.
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:
- Emulates OS preference – launches Chrome with
--force-dark-modeor setsprefers-color-schemevia the DevTools Protocol. - 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.
- Detects UI regressions – compares screenshots against a baseline for each persona, logging any deviation in contrast, layout shift, or missing elements.
- 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
- Verify that clicking the button toggles the
darkclass. - Ensure that after a page reload, the class reflects the stored value.
- Confirm that the button’s
aria-labelupdates (optional) to reflect the current state for screen‑reader users.
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
- Render the page with
matchMedia('(prefers-color-scheme: dark)').matchesset totrueandfalse. - Use
getComputedStyle(document.documentElement)to confirm that the variables switch correctly.
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
- Enable “Force colors” in Chrome DevTools → Rendering → Emulate CSS media feature
forced-colors. - Verify that the button’s background image disappears and the fallback styling remains legible.
- Ensure that hit‑target size is not reduced below 44 × 44 px (touch target guideline).
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:
- The server‑side template does not read the user’s preference, delivering the default light stylesheet.
- A service worker caches the light‑mode CSS and serves it despite the dark‑mode request.
- A client‑side hydration mismatch occurs: the server renders light mode, the client switches to dark, causing a flash of incorrect style (FOIT).
Detection
- Perform a round‑trip: log in as a test user, set theme to dark, log out, log back in, and verify that the initial HTML contains the dark‑mode classes or the correct
tag. - Disable caching headers: inspect the
Content-TypeandCache-Controlof CSS responses to ensure they vary with aVary: Accept-Cookie, Cookieheader when the theme is user‑specific.
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 without the color-scheme attribute, it may remain light while the parent page is dark, creating a visual seam.
Mitigation
- Add
color-scheme: light dark;to the iframe’s root element or set the attribute. - If the widget provider does not support dark mode, consider a wrapper that forces a light background for the iframe while keeping the surrounding page dark, or provide a toggle to disable the widget in dark mode.
Server‑Side Rendered CSS
When using CSS‑in‑JS libraries that extract styles at build time (e.g., Styled Components with ServerStyleSheet), the extracted CSS may only contain the light variant if the dark‑mode classes are not present during the initial render pass.
Solution
- Ensure that the server render pass includes the dark‑mode class (e.g., by setting a cookie or reading
req.headers['accept']forsec-ch-prefers-color-scheme). - Alternatively, generate a static dark‑mode stylesheet at build time and load it conditionally via
.
Font Loading and Contrast
Web fonts may have different weights or glyph designs that affect perceived contrast. A light‑weight font may appear thinner on a dark background, reducing legibility even if the contrast ratio meets WCAG on paper.
Approach
- Use variable fonts with a
wghtaxis that can be increased in dark mode (font-weight: 600vs400). - Test with actual devices; contrast calculators assume uniform stroke width, which may not hold for ultra‑light fonts.
Checklist for Dark Mode Release
| Item | Description | Verification Method |
|---|---|---|
| OS preference respected | Page reads prefers-color-scheme on load. | Toggle OS theme, reload, inspect computed colors. |
| Manual toggle works | UI control switches theme instantly and persists. | Click toggle, verify class/storage, reload, verify persistence. |
| Contrast compliance | All text/UI meets 4.5:1 (AA) for body, 3:1 for large text. | Run automated contrast tool (axe, Lighthouse) on dark mode. |
| Focus visibility | Focus outline ≥ 2px and contrasts ≥ 3:1. | Keyboard tab navigation, visual inspection or automated rule. |
| Reduced motion | Animations respect prefers-reduced-motion. | Enable OS setting, verify no disruptive motion. |
| Forced colors | UI remains usable when forced-colors: active. | DevTools forced‑colors mode, check for missing backgrounds. |
| Print stylesheet | Print output is legible (light background or sufficient contrast). | Print Preview, verify no dark backgrounds wasting toner. |
| Third‑party widgets | Iframes respect color-scheme or are wrapped appropriately. | Inspect iframe attributes, verify no visual seam. |
| Server‑side theme consistency | Initial HTML matches user‑stored theme. | Authenticated round‑trip test, check for FOIT. |
| Font legibility | Font weight/size remains readable in dark mode. | Manual inspection on multiple devices, consider variable font weight. |
| No hard‑coded colors | All colors derived from CSS variables or theme tokens. | Search repository for # or rgb( outside variable definitions. |
| Accessibility announcements | Live region announces theme change. | Screen‑reader test, verify ARIA label updates. |
| Performance | Theme switch does not cause layout thrashing > 50ms. | Measure with Performance panel, ensure style recalc < frame budget. |
| Regression scripts generated | Autonomous tool outputs reproducible test files. | Run SUSA, verify generated Playwright/Appium scripts pass. |
Closing Takeaways
Dark mode testing is a distinct quality gate that touches visual design, CSS architecture, accessibility, and even performance. By treating it as a first‑class concern—complete with a dedicated test matrix, manual exploratory steps, and automated checks—you catch defects that would otherwise slip into production and erode user trust.
Key practices to internalize:
- Define the design token baseline – every color, elevation, and shadow should be expressed through CSS variables or a theme object; this makes automated assertions straightforward.
- Validate at multiple layers – unit tests for variable correctness, integration tests for component rendering, end‑to‑end tests for user flows, and visual regression for pixel‑level fidelity.
- Leverage tooling that respects media features – Playwright’s
colorSchemeoption, Lighthouse’s contrast audit, and axe’s automated rules give you fast feedback in CI. - Embrace autonomous, persona‑driven exploration – tools like SUSA expose edge cases that scripted tests never consider, such as rapid toggling under high‑contrast mode or the interaction between a power‑user’s keyboard shortcuts and a theme switch.
- Document and persist user choice – store the preference in a durable location (localStorage, backend) and restore it on every navigation, preventing the frustrating “flash of light” when a returning user lands on the page.
- Test the extremes – forced colors, reduced motion, print, and third‑party iframes are not edge cases; they are common user configurations that must work.
When these habits become part of your definition of done, dark mode ceases to be an afterthought and becomes a reliable, accessible feature that enhances the user experience for everyone who prefers a darker interface. Happy testing.
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free