ScreenshotNeo

BlogHow-to

How to Compare Screenshots of a Web App Across Light and Dark Themes

Build reliable light and dark visual regression checks with Playwright, separate baselines, stable capture conditions, and deliberate review of diffs.

By the ScreenshotNeo team4 October 20269 min read

Compare each theme against its own reviewed reference screenshot: capture the page once with the browser color scheme set to light, and once with it set to dark. In later runs, compare light to the light reference and dark to the dark reference. A direct light-versus-dark diff mostly reports intended theme changes, so it cannot tell you whether either theme regressed.

For a Playwright project, use its screenshot assertions and emulate the color scheme explicitly. Keep the browser build, operating environment, viewport, page state, and capture timing consistent between baseline creation and subsequent runs. Review detected changes before accepting a new baseline. [Playwright visual comparisons] [Playwright emulation]

1. Decide what the comparison should cover

Choose representative pages and states before generating references. Include more than the page background: navigation, forms, dialogs, charts, icons, illustrations, photographs, borders, focus indicators, and disabled states can all render differently under a theme. Images can also respond to the selected color scheme. [web.dev: prefers-color-scheme]

  • Choose the viewport and browser versions in scope. Use the same choices for baseline and comparison runs.
  • Cover important interaction states, such as an open menu, validation error, focused field, or modal.
  • Decide how to control genuinely variable content, such as timestamps or rotating data. Mask only the changing region, or provide deterministic test data; a large mask can conceal a real theme defect.
  • Check both themes when the page includes media queries, theme-specific assets, or user-selectable theme settings.

Prefer a small set of meaningful, repeatable states over screenshots of arbitrary pages. A screenshot can show a visual difference, but it cannot decide whether that difference is a bug or an intentional design change.

2. Configure Playwright to capture both themes

The following is a runnable Playwright Test example. It uses a separate test and therefore a separate snapshot for each color scheme. Replace the example URL with a route in your app. Run it once to create the initial references, review them, then commit the accepted snapshots with the test.

import { test, expect } from '@playwright/test';

const appUrl = process.env.APP_URL ?? 'http://127.0.0.1:3000';

for (const colorScheme of ['light', 'dark'] as const) {
  test(`home page renders correctly in ${colorScheme} theme`, async ({ page }) => {
    await page.emulateMedia({ colorScheme });
    await page.setViewportSize({ width: 1440, height: 1000 });
    await page.goto(appUrl, { waitUntil: 'networkidle' });

    // Replace this with an app-specific ready condition when available.
    await page.locator('main').waitFor({ state: 'visible' });

    await expect(page).toHaveScreenshot(`home-${colorScheme}.png`, {
      fullPage: true,
      animations: 'disabled',
      caret: 'hide',
    });
  });
}

Save it, for example, as tests/theme.spec.ts. A minimal Playwright Test configuration can pin the browser project and snapshot directory:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  snapshotDir: './tests/snapshots',
  use: {
    browserName: 'chromium',
    viewport: { width: 1440, height: 1000 },
  },
});

Install and run using the Playwright Test package and its browser installer in the project. Consult the current official setup instructions for your package manager and CI environment: Playwright getting started.

Why this produces separate baselines

The snapshot name includes the theme, and each test sets the emulated scheme before navigation. Playwright stores and compares each named screenshot independently. Thus a later light run is checked against home-light.png, while a dark run is checked against home-dark.png. Keep the naming convention stable so snapshot files remain easy to identify and review. [Playwright visual comparisons]

If your app has a theme toggle that stores a preference in local storage or a cookie, set that app preference as well as emulating the media feature. They are separate controls: the browser emulation affects prefers-color-scheme, while app state may override it.

3. Create and review the reference screenshots

  1. Start the app with deterministic data and a known configuration.
  2. Run the tests in the same browser and operating environment intended for future comparisons.
  3. When Playwright reports missing snapshots, inspect each light and dark image. Confirm that the route, theme, viewport, and loaded state are correct.
  4. Accept the initial snapshots as references only when they represent the intended design. Commit the snapshot files with the test.
  5. On later runs, inspect the diff image and determine whether each change is expected, harmless rendering noise, or a defect.
  6. Update a baseline only after reviewing and accepting the visual change. Treat baseline updates as a code review decision, not as a routine way to make a failing test pass.

Playwright notes that rendering can vary with operating system, browser version, settings, hardware, power source, and headless mode. Keep the baseline and comparison environment aligned, especially in CI. [Playwright visual comparisons]

4. Control capture stability and diff sensitivity

Playwright screenshot assertions wait for consecutive screenshots to stabilize before comparing them. Screenshot options let you tune comparison thresholds and control capture behavior. A tolerance can absorb small rendering differences, but a permissive threshold can hide a genuine regression. Start with the default or a narrow tolerance, inspect reported diffs, and change it only when you can explain the noise it addresses. [Playwright screenshot assertion options]

Control Use it for Watch out for
Consistent browser and host environment Reducing rasterization and font-rendering variation Local and CI images may differ even when the app code does not
Stable page-ready condition Waiting for app data or critical components networkidle can be unsuitable for pages with persistent connections or background requests
Disabled animations and hidden caret Removing transient animation and typing-caret differences Do not disable behavior if animation itself is what you are testing
Targeted masks or controlled test data Handling truly dynamic regions Broad masks can hide theme regressions
Pixel or color threshold Allowing small, understood raster differences Higher tolerance reduces the test’s ability to catch subtle changes

For long pages, full-page capture can expose lower sections but increases the area being compared and may interact with lazy loading. Ensure the content you need is loaded before capture; the screenshot option alone does not prove every image or data request succeeded.

5. Review visual correctness and accessibility separately

A pixel diff flags change, not quality. For each meaningful difference, check whether the change is intentional, whether all key elements remain visible, and whether theme-specific media and icons still make sense. In both themes, inspect text, labels, borders, focus indicators, disabled states, charts, and selected controls.

Run contrast checks independently in both themes. WCAG guidance treats text contrast as important for readability; a screenshot comparison is not an accessibility evaluation. [W3C: Contrast (Minimum)] [WCAG 2.2]

6. Capture a page with cURL, Python, or Node.js

For a one-off comparison, you can capture a URL twice and inspect the resulting images. A screenshot service may not let you control your app’s theme unless the page responds to a query parameter, cookie, or other application state. Configure your app to select a theme through a URL or cookie when using URL-based capture, then capture each state separately. The examples below save captures as files; they do not perform a visual diff. Use Playwright assertions or an image comparison tool for the comparison step.

cURL

curl -L 'http://127.0.0.1:3000/?theme=light' -o light.png
curl -L 'http://127.0.0.1:3000/?theme=dark' -o dark.png

Python

import requests

base = 'http://127.0.0.1:3000/'
for theme in ('light', 'dark'):
    response = requests.get(base, params={'theme': theme}, timeout=30)
    response.raise_for_status()
    with open(f'{theme}.png', 'wb') as image:
        image.write(response.content)

Node.js

for (const theme of ['light', 'dark']) {
  const url = new URL('http://127.0.0.1:3000/');
  url.searchParams.set('theme', theme);
  const response = await fetch(url);
  if (!response.ok) throw new Error(`${theme}: HTTP ${response.status}`);
  const image = new Uint8Array(await response.arrayBuffer());
  await import('node:fs/promises').then(({ writeFile }) =>
    writeFile(`${theme}.png`, image));
}

These generic HTTP examples assume your app serves an image at those URLs, which ordinary web apps do not. For actual browser screenshots, use Playwright as above or a screenshot API. If the app only supports OS preference emulation and has no theme URL or cookie, use Playwright’s page.emulateMedia({ colorScheme }) rather than these URL examples.

7. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Its API accepts a URL and returns a PNG, JPEG, WebP, or PDF. For theme comparisons, provide a page URL that selects the desired theme through your app’s own state, and save one capture per theme. See the ScreenshotNeo API documentation.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Replace the example URL with a route that selects light or dark mode in your app; make one request per theme. Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up for free: 1,000 screenshots a month, no card required.

8. Performance, reliability, and cost

  • Performance: Each additional page state, theme, viewport, and browser project adds capture work. Start with representative routes and states, then expand coverage around areas that have caused defects. Full-page screenshots can take longer and create larger snapshots than viewport captures.
  • Reliability: Run comparisons in a stable, repeatable environment. Wait for a meaningful app-ready condition, make test data deterministic, and keep browser versions aligned. A successful screenshot assertion only verifies the pixels captured under those conditions.
  • Cost: Playwright is an open-source browser automation framework; your practical costs are the compute and CI time required to run it. A hosted screenshot API has its own plan and billing rules; check the provider’s documentation and pricing before scaling capture volume. Do not use a screenshot service’s successful response as a substitute for a reviewed visual baseline.

9. Troubleshooting

Symptom Likely cause Fix
Every dark screenshot differs dramatically from the light baseline The test is comparing unlike themes or both runs use the same snapshot name. Set colorScheme explicitly and use distinct snapshot names such as home-light.png and home-dark.png.
The app stays in the wrong theme Application preference overrides the emulated media setting. Set the app’s theme state as well as browser emulation, and verify the page’s effective theme before capture.
Snapshots differ locally and in CI Browser build, operating system, fonts, rendering mode, hardware, or settings differ. Align baseline and CI environments and regenerate baselines only in the environment used for comparison.
Intermittent diffs show loading spinners or incomplete images The capture happens before app data, fonts, or images are ready. Wait for an app-specific ready selector or state. Verify lazy content and assets have loaded before taking the screenshot.
Animated areas create noisy diffs Animation or caret state changes between captures. Disable animations or hide the caret for regression captures, unless those effects are under test.
A threshold makes failures disappear The tolerance is too permissive. Reduce it and review the highlighted diff. Use masks only for understood dynamic regions.
Dark theme looks acceptable in the diff but text is hard to read Pixel similarity does not measure accessibility or contrast. Check text/background contrast and focus visibility separately in both themes.
HTTP capture returns HTML or an error instead of an image The URL is not a screenshot endpoint, or the screenshot service request failed. Use a browser screenshot tool for ordinary web pages; check the API response status and documentation for the service endpoint.

10. Frequently asked questions

Should I compare light mode directly with dark mode?

No. Each theme has intentional differences. Compare each render with the reference for that same theme.

Does emulating prefers-color-scheme test my theme toggle?

It tests browser media preference. If your app has a separate toggle or saved preference, exercise that state too.

Can screenshot diffs prove a page is accessible?

No. They reveal visual changes. Evaluate contrast and other accessibility requirements separately.

When should I update a baseline?

After reviewing the visual change and deciding it is intended. Include the updated reference in the same review as the code change that caused it.