Visual Regression Testing for Dark Mode: Catch the UI Bugs That Only Appear at Night
Dark mode is table stakes for modern applications. When you ship a design system that supports it, you're shipping twice the visual surface area: every component, every color, every icon needs to look correct in both modes. The visual regression surface doubles, but most teams' visual test suites don't.
The bugs that slip through are characteristic: a modal with a hardcoded background: #ffffff that shows up as a white box in dark mode, a chart with dark axis labels on a dark background, a loading skeleton that renders as black bars. These are embarrassing, user-facing bugs that no functional test would catch — they require visual comparison.
Why Dark Mode Introduces Visual Bugs
Most dark mode issues stem from one root cause: hardcoded colors.
/* ❌ The source of most dark mode bugs */
.card {
background: #ffffff; /* Hardcoded white — invisible in dark mode */
color: #333333; /* Hardcoded dark text — invisible on dark bg */
border: 1px solid #e5e7eb; /* Hardcoded gray — too subtle or too harsh */
}
/* ✅ Correct: use semantic tokens via CSS variables */
.card {
background: var(--color-surface); /* Defined per theme */
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
When you set up visual regression tests, these bugs become immediately visible as screenshot diffs.
Setting Up Visual Regression Tests
Playwright's toHaveScreenshot() API is purpose-built for this:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'light-mode',
use: {
...devices['Desktop Chrome'],
colorScheme: 'light',
},
},
{
name: 'dark-mode',
use: {
...devices['Desktop Chrome'],
colorScheme: 'dark',
},
},
{
name: 'mobile-dark',
use: {
...devices['iPhone 15'],
colorScheme: 'dark',
},
},
],
// Configure screenshot comparison
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.02, // Allow 2% pixel difference
animations: 'disabled', // Freeze CSS animations
},
},
});
Component-Level Visual Tests
Test individual components in isolation in both themes:
// tests/visual/components.test.ts
import { test, expect } from '@playwright/test';
// Component routes served by Storybook or dedicated component viewer
const BASE = process.env.STORYBOOK_URL || 'http://localhost:6006';
test('Button component renders correctly', async ({ page }, testInfo) => {
await page.goto(`${BASE}/iframe.html?id=components-button--all-variants`);
await page.waitForLoadState('networkidle');
// Disable transitions for clean screenshots
await page.addStyleTag({
content: '*, *::before, *::after { transition: none !important; animation: none !important; }',
});
await expect(page).toHaveScreenshot(`button-variants-${testInfo.project.name}.png`);
});
test('Form inputs render correctly', async ({ page }, testInfo) => {
await page.goto(`${BASE}/iframe.html?id=components-input--all-states`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`form-inputs-${testInfo.project.name}.png`);
});
test('DataTable with data renders correctly', async ({ page }, testInfo) => {
await page.goto(`${BASE}/iframe.html?id=components-datatable--with-data`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`datatable-${testInfo.project.name}.png`);
});
Full-Page Route Visual Tests
// tests/visual/routes.test.ts
const ROUTES_TO_TEST = [
{ name: 'homepage', path: '/' },
{ name: 'pricing', path: '/pricing' },
{ name: 'login', path: '/login' },
{ name: 'dashboard', path: '/dashboard' },
{ name: 'product-page', path: '/products/sample-product' },
];
for (const route of ROUTES_TO_TEST) {
test(`${route.name} page visual regression`, async ({ page }, testInfo) => {
await page.goto(route.path);
await page.waitForLoadState('networkidle');
// Hide dynamic content that changes between runs
await page.addStyleTag({
content: `
[data-testid="timestamp"],
[data-testid="live-activity"],
[data-testid="user-avatar"],
.animate-pulse,
[data-nosnap="true"]
{ visibility: hidden !important; }
`,
});
// Scroll to position to capture important content
await page.evaluate(() => window.scrollTo(0, 0));
await expect(page).toHaveScreenshot(
`${route.name}-${testInfo.project.name}.png`,
{ fullPage: false }, // Viewport only by default
);
});
}
Testing Theme Switching in the Browser
Beyond OS-level color scheme preference, many apps have a manual theme toggle. Test that switching works correctly:
// tests/visual/theme-switching.test.ts
test('theme toggle switches between light and dark', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// Verify we start in system light mode
const htmlEl = page.locator('html');
await expect(htmlEl).not.toHaveClass(/dark/);
// Take light mode screenshot
await expect(page).toHaveScreenshot('dashboard-light.png');
// Click theme toggle
await page.click('[data-testid="theme-toggle"]');
// Wait for transition to complete
await page.waitForTimeout(300); // Let CSS transitions finish
// Verify dark mode is applied
await expect(htmlEl).toHaveClass(/dark/);
// Take dark mode screenshot
await expect(page).toHaveScreenshot('dashboard-dark.png');
// Toggle back
await page.click('[data-testid="theme-toggle"]');
await page.waitForTimeout(300);
await expect(htmlEl).not.toHaveClass(/dark/);
});
test('theme preference is persisted across page loads', async ({ page, context }) => {
await page.goto('/dashboard');
// Switch to dark mode
await page.click('[data-testid="theme-toggle"]');
await expect(page.locator('html')).toHaveClass(/dark/);
// Reload page
await page.reload();
// Should still be in dark mode (persisted to localStorage)
await expect(page.locator('html')).toHaveClass(/dark/);
// Open in new tab — should inherit preference
const newTab = await context.newPage();
await newTab.goto('/pricing');
await expect(newTab.locator('html')).toHaveClass(/dark/);
});
Contrast Ratio Testing
Visual regression catches layout bugs, but contrast ratio testing catches accessibility failures — text that's technically visible but below WCAG AA minimum contrast:
// tests/visual/contrast.test.ts
import { test, expect } from '@playwright/test';
test.use({ colorScheme: 'dark' });
test('all text elements meet WCAG AA contrast in dark mode', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// Collect all text elements with computed colors
const failingElements = await page.evaluate(() => {
const issues: Array<{ text: string; ratio: number; required: number }> = [];
function getContrastRatio(rgb1: string, rgb2: string): number {
// Simplified luminance calculation
const getLuminance = (rgb: string) => {
const match = rgb.match(/\d+/g);
if (!match) return 0;
const [r, g, b] = match.map((n) => {
const val = parseInt(n) / 255;
return val <= 0.03928 ? val / 12.92 : ((val + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const l1 = getLuminance(rgb1);
const l2 = getLuminance(rgb2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
const textElements = document.querySelectorAll('p, h1, h2, h3, h4, span, a, button, label');
textElements.forEach((el) => {
const styles = getComputedStyle(el);
const ratio = getContrastRatio(styles.color, styles.backgroundColor);
const fontSize = parseFloat(styles.fontSize);
const required = fontSize >= 18 || (fontSize >= 14 && styles.fontWeight >= '700') ? 3 : 4.5;
if (ratio < required && el.textContent?.trim()) {
issues.push({
text: (el.textContent || '').trim().substring(0, 50),
ratio: Math.round(ratio * 10) / 10,
required,
});
}
});
return issues;
});
if (failingElements.length > 0) {
console.log('Contrast failures:', failingElements);
}
expect(failingElements, `${failingElements.length} elements fail WCAG AA contrast in dark mode`).toHaveLength(0);
});
Related articles: Also see the complete visual regression testing reference guide, when snapshot testing complements visual regression coverage, and ensuring theme consistency across every browser and viewport.
Dark Mode Testing Checklist
| Category | Test | Tool |
|---|---|---|
| OS color scheme | Screenshots with colorScheme: 'dark' |
Playwright |
| Manual toggle | Theme class applied + persisted | Playwright assertions |
| CSS variable coverage | No hardcoded colors on interactive elements | Code audit + visual tests |
| Contrast ratio | WCAG AA 4.5:1 for normal text | Custom contrast audit |
| Images & icons | SVGs and images visible in dark | Visual regression |
| Charts & data viz | Axes and labels readable | Visual regression |
| Modals & overlays | Not hardcoded white backgrounds | Visual regression |
| 3rd party widgets | Chat, analytics, support tools in dark mode | Visual regression |
Running dark mode visual regression tests in CI catches regressions within the same pull request that introduced them — before the bug ships to production and before users notice.
Catch dark mode and visual regressions automatically: Try ScanlyApp free and run automated screenshot comparisons on every push across light and dark themes.
