ScreenshotNeo

BlogHow-to

How to Test a Website’s Dark Mode with Puppeteer Screenshots

Emulate light and dark preferences in Puppeteer, capture comparable screenshots, and check the rendered result with practical assertions and fixes.

By the ScreenshotNeo team4 October 20267 min read

To test a website’s dark mode with Puppeteer, emulate the prefers-color-scheme media feature, wait for the application’s own visual readiness signal, and capture a screenshot. Repeat with the light preference under the same browser, viewport, data, and timing conditions. You can verify what preference the page sees with window.matchMedia('(prefers-color-scheme: dark)').matches; that confirms the emulated preference, not that every component is styled correctly.

1. Set up a repeatable Puppeteer capture

Install Puppeteer in a Node.js project if it is not already installed:

npm install puppeteer

Save this as dark-mode-capture.mjs. It captures both schemes, prints the browser’s media-query result, and saves full-page PNGs. Replace the example URL and readiness selector with those for your application.

import puppeteer from 'puppeteer';

const url = process.env.TARGET_URL ?? 'https://example.com';
const readySelector = process.env.READY_SELECTOR;
const browser = await puppeteer.launch({ headless: true });

try {
  const page = await browser.newPage({
    viewport: { width: 1440, height: 1000 },
    deviceScaleFactor: 1,
  });

  for (const scheme of ['light', 'dark']) {
    // Set the preference before navigation so the app can use it on first render.
    await page.emulateMediaFeatures([
      { name: 'prefers-color-scheme', value: scheme },
    ]);

    await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });

    // Prefer an app-specific readiness signal when available.
    if (readySelector) {
      await page.waitForSelector(readySelector, { timeout: 15000 });
    }

    // This checks the preference exposed to page scripts and CSS.
    const preferenceMatches = await page.evaluate((scheme) =>
      window.matchMedia(`(prefers-color-scheme: ${scheme})`).matches,
      scheme,
    );
    if (!preferenceMatches) {
      throw new Error(`Page did not report the ${scheme} color preference`);
    }

    await page.screenshot({
      path: `${scheme}.png`,
      fullPage: true,
      animations: 'disabled',
    });
  }
} finally {
  await browser.close();
}

Run it with:

TARGET_URL=https://your-site.example READY_SELECTOR='main' node dark-mode-capture.mjs

On Windows PowerShell, set the variables first, then run Node:

$env:TARGET_URL = 'https://your-site.example'
$env:READY_SELECTOR = 'main'
node .\dark-mode-capture.mjs

The selector is optional. Choose one that means the content under test has appeared; a generic main element may exist before its data or images are ready. For client-rendered pages, wait for a route-specific element, a known loading indicator to disappear, or an application-defined ready state. A network idle condition can help, but it does not guarantee that animations, delayed content, fonts, or application rendering have settled.

2. Make the two captures comparable

Keep the following conditions identical between the light and dark runs so a visual difference is attributable to the color preference:

  • URL, query parameters, account state, and page data.
  • Browser and Puppeteer versions, viewport dimensions, and device scale factor.
  • Readiness selector or other application-specific wait condition.
  • Screenshot options, including full-page versus viewport capture.

The example sets the preference before navigation. This lets the page’s initial render use it and can avoid briefly capturing the default theme. If your app changes theme only after hydration or after a stored user setting is read, use a deterministic test account and wait for the final theme state before capture.

3. Verify the preference and inspect the actual design

The matchMedia assertion proves that the browser exposes the requested preference to the page. It does not prove that the stylesheet or components respond to it. Review both images for:

  • Text and background contrast, including secondary text and placeholder text.
  • Links, buttons, borders, separators, disabled controls, and focus indicators.
  • Icons, logos, charts, illustrations, and images that may need a different treatment.
  • Overlays, menus, dialogs, and content that appears after interaction.
  • Form controls and scrollbars, which can be affected by the browser’s supported color scheme.

For visual regression testing, save a known-good capture for each scheme and compare new captures against the matching baseline. Make the environment deterministic first: dynamic timestamps, rotating content, animation, and unstable remote assets can create differences unrelated to dark mode. A screenshot is evidence of one rendered state in one browser and viewport; pair image review with assertions for important behavior and test the browser and viewport combinations your project supports.

4. Understand the CSS and native control behavior

prefers-color-scheme reflects the user’s requested color preference. A dark preference is exposed as dark; the light value also covers the absence of an active preference. See the [MDN media feature reference](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme).

@media (prefers-color-scheme: dark) {
  :root {
    color: #f3f4f6;
    background: #111827;
  }

  a {
    color: #93c5fd;
  }
}

The CSS color-scheme property tells the browser which schemes an element supports and can affect browser-provided surfaces such as form controls and scrollbars. It does not automatically theme your application’s custom components. The [MDN color-scheme reference](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme) describes the property and its effects.

:root {
  color-scheme: light dark;
}

A document can declare scheme support early in its head so the browser knows the preferred scheme during initial rendering:

<meta name="color-scheme" content="light dark">

MDN recommends placing this declaration before styles in the document head; see [MDN’s color-scheme guidance](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme#declaring_color_scheme_preferences). Use it when the page supports both schemes, and still provide theme-aware styles for your own interface.

5. Useful capture options and variations

Puppeteer’s Page.screenshot() captures the page. Its screenshot options include path to write an image and fullPage to capture beyond the viewport. The API also supports taking a screenshot of a specific element with ElementHandle.screenshot(). See the [Puppeteer screenshot guide](https://pptr.dev/guides/screenshots) and [Page API](https://pptr.dev/api/puppeteer.page.screenshot).

  • Viewport-only shot: omit fullPage: true to capture just the configured viewport.
  • Element shot: locate the component under test and call await element.screenshot({ path: 'dark-card.png' }). This is useful for isolated component checks.
  • Device scale: set deviceScaleFactor when creating the page to match the resolution you want to test; use the same value for each theme.
  • Animation: animations: 'disabled' reduces capture variation from supported animations. It does not settle asynchronous data or every possible visual effect.
  • Timing: Puppeteer navigation supports conditions such as networkidle2; treat them as signals, not proof that the application is ready. The [navigation guide](https://pptr.dev/guides/page-interactions#waiting-for-navigation) explains navigation waits.

6. Troubleshooting

Symptom Likely cause Fix
The dark screenshot looks light The site does not use prefers-color-scheme, or a saved app theme overrides it. Check the media query with matchMedia; inspect the app’s theme-selection logic and clear or control persisted theme settings.
The assertion passes, but components remain light The preference is available, but those styles or components do not respond to it. Inspect component styles, theme tokens, and styles loaded after hydration. Add explicit theme coverage where needed.
The capture shows a loading state or missing data Navigation completed before the application-specific content was ready. Wait for a meaningful selector, a loading state to disappear, or a deterministic application readiness signal.
Images, fonts, or charts differ between runs Assets are delayed, remote, or dynamic, or the capture occurs before they render. Wait for the relevant assets or component state, stabilize test data, and use the same readiness rule for both schemes.
The script times out waiting for network idle Long polling, analytics, or persistent requests can keep the network active. Use a selector or app readiness condition as the primary wait. Choose a navigation condition suitable for the page rather than relying on network idle universally.
Native inputs or scrollbars look unexpected The browser’s native UI follows color-scheme and may not match custom component styling. Declare supported schemes where appropriate and test native controls in the browser versions you support.
Snapshots vary on every run Animation, timestamps, rotating content, viewport drift, or remote data is not controlled. Disable animations where possible, freeze or seed test data, and hold viewport, browser version, and timing criteria constant.

7. Performance, reliability, and cost

Each theme requires a page render and screenshot, so capturing both takes at least two page states. Reuse a browser process for the pair, as the example does, and close it in a finally block so failures do not leave browser processes running. Keep waits specific: long global timeouts slow feedback, while overly short waits create flaky images.

Full-page captures can use more memory and take longer than viewport or element captures, especially for long pages and large assets. Capture only the area needed for the check. For reliable comparisons, pin the browser/runtime version in your project and keep test data and readiness conditions stable. Puppeteer is software and the screenshots are generated in your browser environment; the dossier provides no benchmark or universal runtime figure, so measure on your own pages and CI runners.

Or skip the browser setup

ScreenshotNeo can capture a URL through one API request; see the [ScreenshotNeo site](https://screenshotneo.com) and [API documentation](https://screenshotneo.com/docs/). Example request:

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

Use ScreenshotNeo when you want a hosted screenshot without maintaining the browser capture setup. 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; and 1,000 screenshots a month are free with no card, with paid plans starting at $5 for 3,000. Its API supports dark mode among its capture options. [Create a free account](https://screenshotneo.com/account/sign-up/) to try it.

FAQ

Does a passing matchMedia check mean dark mode is correct?

No. It confirms the preference exposed to the page. Inspect the rendered components and add behavior or visual assertions for the parts that matter.

Should I test dark mode only at one viewport?

Use the viewports your project supports. Theme-specific text wrapping, navigation, and overlays can behave differently as the available width changes.

Can I use an element screenshot instead of a full-page image?

Yes. An element capture is useful for a component-level check; use a full-page image when page-wide surfaces, layout, or overlays are part of the requirement.

References