Back to Blog

Visual Regression Testing for Dark Mode: Catch the UI Bugs That Only Appear at Night

Dark mode is no longer optional — 81% of smartphone users enable it. But dark mode introduces a parallel set of visual bugs: low contrast, hardcoded hex colors, invisible icons, and flickering during theme transition. This guide covers automated visual regression testing for dark mode and dynamic themes using Playwright.

Published

7 min read

Reading time

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.

Related Posts

Automating MFA and 2FA Testing: Stop Skipping Auth Flows in Your Test Suite
Mobile & Cross-Platform
6 min read

Automating MFA and 2FA Testing: Stop Skipping Auth Flows in Your Test Suite

Multi-factor authentication is critical for security, but it is notoriously painful to test. OTP codes that expire in 30 seconds, SMS delivery delays, and TOTP clock sync issues create a testing nightmare. Here's how to automate MFA testing without relying on real SMS delivery or manual code entry.