How to Capture Dark Mode Screenshots Programmatically

Capture websites in both light and dark themes without clicking a single toggle. Automate theme testing with code.

📋 Table of Contents

How Dark Mode Works on the Web

Modern websites detect the user's theme preference through the CSS media query prefers-color-scheme. When a browser reports prefers-color-scheme: dark, the site switches to its dark color palette:

/* Site CSS — standard dark mode implementation */
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --surface: #f5f5f5;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0a0a0a;
    --text: #e5e5e5;
    --surface: #1a1a1a;
  }
}

body {
  background: var(--bg);
  color: var(--text);
}

The key insight is that dark mode is triggered at the browser level, not by a CSS class or a JavaScript toggle. To capture a dark mode screenshot programmatically, you need to tell the browser to report prefers-color-scheme: dark before taking the screenshot.

Some sites also use JavaScript-based theme toggles (stored in localStorage or a cookie). These require a different approach, which we'll cover in the forcing dark mode section.

Method 1: Puppeteer

Puppeteer lets you emulate the prefers-color-scheme media feature using Chrome DevTools Protocol:

const puppeteer = require('puppeteer');

async function captureDarkMode(url, outputPath) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  // Enable dark mode at the browser level
  await page.emulateMediaFeatures([
    { name: 'prefers-color-scheme', value: 'dark' },
  ]);

  await page.setViewport({ width: 1280, height: 720 });
  await page.goto(url, { waitUntil: 'networkidle0' });
  await page.screenshot({ path: outputPath, fullPage: true });

  await browser.close();
  console.log(`🌙 Dark mode screenshot saved: ${outputPath}`);
}

// Capture both themes
async function captureBothThemes(url) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 720 });

  // Light mode
  await page.emulateMediaFeatures([
    { name: 'prefers-color-scheme', value: 'light' },
  ]);
  await page.goto(url, { waitUntil: 'networkidle0' });
  await page.screenshot({ path: 'light.png', fullPage: true });

  // Dark mode — same page, just switch the preference
  await page.emulateMediaFeatures([
    { name: 'prefers-color-scheme', value: 'dark' },
  ]);
  // Reload to ensure all CSS recalculates
  await page.reload({ waitUntil: 'networkidle0' });
  await page.screenshot({ path: 'dark.png', fullPage: true });

  await browser.close();
}

captureBothThemes('https://github.com');

Important: Always reload the page after switching the color scheme. Some sites cache their initial theme calculation and won't respond to the media feature change without a full page reload.

Method 2: Playwright

Playwright provides a cleaner API for color scheme emulation. You can set it at the browser context level, which means every page opened in that context automatically uses the specified theme:

const { chromium } = require('playwright');

async function captureDarkMode(url, outputPath) {
  const browser = await chromium.launch();

  // Set dark mode at the context level
  const context = await browser.newContext({
    colorScheme: 'dark',
    viewport: { width: 1280, height: 720 },
  });

  const page = await context.newPage();
  await page.goto(url, { waitUntil: 'networkidle' });
  await page.screenshot({ path: outputPath, fullPage: true });

  await browser.close();
}

// Capture both themes in parallel
async function captureBothThemes(url) {
  const browser = await chromium.launch();

  const [lightCtx, darkCtx] = await Promise.all([
    browser.newContext({ colorScheme: 'light', viewport: { width: 1280, height: 720 } }),
    browser.newContext({ colorScheme: 'dark', viewport: { width: 1280, height: 720 } }),
  ]);

  const [lightPage, darkPage] = await Promise.all([
    lightCtx.newPage(),
    darkCtx.newPage(),
  ]);

  await Promise.all([
    lightPage.goto(url, { waitUntil: 'networkidle' }),
    darkPage.goto(url, { waitUntil: 'networkidle' }),
  ]);

  await Promise.all([
    lightPage.screenshot({ path: 'light.png', fullPage: true }),
    darkPage.screenshot({ path: 'dark.png', fullPage: true }),
  ]);

  await browser.close();
  console.log('✅ Both themes captured');
}

captureBothThemes('https://tailwindcss.com');

Playwright's context-level approach is arguably cleaner than Puppeteer's — you create two separate contexts (one light, one dark) and capture them in parallel. No reloading required.

Method 3: Screenshot API (Easiest)

If you don't want to manage Chromium instances, install browser dependencies, or deal with headless browser quirks, a Screenshot API is the simplest approach. SnapAPI's dark_mode parameter does everything in a single HTTP call:

// Dark mode — one line
const darkUrl = `https://snapapi.alterra-solutions.com/v1/screenshot?` +
  `url=https://github.com&dark_mode=true&format=png&api_key=YOUR_KEY&response=binary`;

// Light mode — just omit dark_mode (defaults to false)
const lightUrl = `https://snapapi.alterra-solutions.com/v1/screenshot?` +
  `url=https://github.com&format=png&api_key=YOUR_KEY&response=binary`;

Or with a proper Node.js function:

const fs = require('fs');

async function captureWithTheme(url, darkMode, outputPath) {
  const params = new URLSearchParams({
    url,
    api_key: process.env.SNAPAPI_KEY,
    dark_mode: darkMode.toString(),
    format: 'png',
    width: '1280',
    height: '720',
    full_page: 'true',
    delay: '1500',
    response: 'binary',
  });

  const apiUrl = `https://snapapi.alterra-solutions.com/v1/screenshot?${params}`;
  const response = await fetch(apiUrl);

  if (!response.ok) throw new Error(`Screenshot failed: ${response.status}`);

  const buffer = Buffer.from(await response.arrayBuffer());
  fs.writeFileSync(outputPath, buffer);
  console.log(`${darkMode ? '🌙' : '☀️'} Saved: ${outputPath}`);
}

// Capture both themes
async function main() {
  await captureWithTheme('https://github.com', false, 'github-light.png');
  await captureWithTheme('https://github.com', true,  'github-dark.png');
}

main();

Why use an API? You skip 500MB+ of Chromium dependencies, system library installation, font packages, and the operational overhead of running headless browsers. One HTTP call, one parameter.

Capturing Light & Dark Side-by-Side

A common use case is generating side-by-side comparison images for documentation, app store listings, or design review. Here's how to stitch light and dark screenshots together using sharp:

const sharp = require('sharp');
const fs = require('fs');

async function createSideBySide(lightPath, darkPath, outputPath) {
  const lightImg = sharp(lightPath);
  const darkImg = sharp(darkPath);

  const lightMeta = await lightImg.metadata();
  const darkMeta = await darkImg.metadata();

  // Use the taller image's height
  const height = Math.max(lightMeta.height, darkMeta.height);
  const totalWidth = lightMeta.width + darkMeta.width + 20; // 20px gap

  await sharp({
    create: {
      width: totalWidth,
      height: height + 60, // Extra space for labels
      channels: 4,
      background: { r: 245, g: 245, b: 245, alpha: 1 },
    },
  })
    .composite([
      { input: await lightImg.toBuffer(), left: 0, top: 60 },
      { input: await darkImg.toBuffer(), left: lightMeta.width + 20, top: 60 },
      // You could add text labels with SVG overlays here
    ])
    .toFile(outputPath);

  console.log(`📊 Side-by-side comparison saved: ${outputPath}`);
}

createSideBySide('github-light.png', 'github-dark.png', 'comparison.png');

Forcing Dark Mode on Sites That Don't Support It

Not every website supports prefers-color-scheme. Some sites use JavaScript toggles, and others don't offer dark mode at all. Here are techniques to handle both cases:

Technique 1: Inject CSS to simulate dark mode

Use SnapAPI's css parameter (or Puppeteer's page.addStyleTag()) to inject a dark color filter:

// Force dark mode via CSS injection
const params = new URLSearchParams({
  url: 'https://example.com',
  api_key: 'YOUR_KEY',
  format: 'png',
  response: 'binary',
  // Inject a CSS filter that inverts colors and adjusts hue
  css: `html {
    filter: invert(1) hue-rotate(180deg);
  }
  /* Prevent images from being inverted */
  img, video, svg, [style*="background-image"] {
    filter: invert(1) hue-rotate(180deg);
  }`,
});

This invert() + hue-rotate() trick inverts all colors, then re-inverts images so they look normal. It's not perfect — gradient backgrounds and colored elements may look slightly off — but it works surprisingly well for text-heavy pages.

Technique 2: Trigger JavaScript-based theme toggles

For sites that use localStorage or cookies for theme switching, inject JavaScript before capture:

// In Puppeteer/Playwright — set localStorage before navigation
await page.evaluateOnNewDocument(() => {
  // Common localStorage keys for dark mode
  localStorage.setItem('theme', 'dark');
  localStorage.setItem('color-mode', 'dark');
  localStorage.setItem('darkMode', 'true');
  // For cookie-based themes
  document.cookie = 'theme=dark; path=/';
});

await page.goto(url, { waitUntil: 'networkidle0' });
await page.screenshot({ path: 'dark-forced.png' });

Dark Mode in CI/CD Testing

If your app supports dark mode, you should test both themes on every deploy. Here's a GitHub Actions workflow that captures both light and dark screenshots and fails the build if visual regressions are detected:

# .github/workflows/dark-mode-test.yml
name: Dark Mode Visual Test

on:
  pull_request:
    branches: [main]

jobs:
  theme-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        theme: [light, dark]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Capture ${{ matrix.theme }} mode screenshots
        env:
          SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }}
          THEME: ${{ matrix.theme }}
        run: |
          node capture-theme.js --theme $THEME --output results/$THEME/

      - name: Compare against baselines
        run: |
          node compare.js baselines/${{ matrix.theme }}/ results/${{ matrix.theme }}/
          # Exit with error if diff exceeds threshold

      - name: Upload screenshots
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: screenshots-${{ matrix.theme }}
          path: results/${{ matrix.theme }}/

Using GitHub Actions' matrix strategy, light and dark tests run in parallel, cutting your CI time in half.

Common Gotchas

  1. Reload after switching themes. Many sites cache their initial color scheme calculation. If you switch from light to dark on the same page instance, some elements may not update. Always reload the page after changing prefers-color-scheme.
  2. Watch for FOUC (Flash of Unstyled Content). Some sites flash the light theme briefly before dark mode CSS kicks in. Add a 1-2 second delay before capturing to ensure the theme has fully rendered.
  3. Image and SVG inversions. If you're using the CSS invert() trick, remember to double-invert images, SVGs, and video elements. Otherwise photos will look like X-ray negatives.
  4. System-level vs. site-level dark mode. prefers-color-scheme emulation affects the CSS media query, but not sites that use JavaScript-based toggles (like GitHub's theme switcher). Check which mechanism the target site uses.
  5. Transparent backgrounds. Some dark mode implementations use transparent or semi-transparent backgrounds. These render differently depending on the canvas background color. Set an explicit background in your screenshot tool to avoid surprises.
  6. Third-party widgets may not respect dark mode. Embedded widgets (chat bubbles, ads, cookie banners) often ignore prefers-color-scheme. They'll appear as bright elements on an otherwise dark page, causing visual diff false positives.

Dark Mode Screenshots in One API Call

100 free screenshots per month. Just add dark_mode=true to your request.

Get Started Free →