Back to Blog
Guide February 26, 2026 · 4 min read

How to Test Dark Mode Rendering Across Devices Automatically

Dark mode bugs only surface when a user reports them. Screenshot your pages in light and dark mode across desktop, mobile, and tablet viewports — catch missed inversions before they ship.

Dark mode bugs are the last to get caught: a white background that didn't invert, text that vanishes against a dark surface, an image with a hardcoded light background. They only surface when a user on dark mode reports them — or when you look at your app on your phone at night.

Here's how to screenshot your pages in both modes across multiple viewports automatically, so you catch these before users do.

Light vs dark — side by side

const PAGEBOLT_API_KEY = process.env.PAGEBOLT_API_KEY;

async function screenshotBothModes(url) {
  const [light, dark] = await Promise.all([
    captureScreenshot(url, { darkMode: false }),
    captureScreenshot(url, { darkMode: true }),
  ]);
  return { light, dark };
}

async function captureScreenshot(url, options = {}) {
  const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
    method: "POST",
    headers: {
      "x-api-key": PAGEBOLT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url,
      fullPage: true,
      blockBanners: true,
      ...options,
    }),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return Buffer.from(await res.arrayBuffer());
}

Full matrix: modes × devices

import fs from "fs/promises";
import path from "path";

const PAGES = [
  { name: "home", url: "https://yourapp.com" },
  { name: "pricing", url: "https://yourapp.com/pricing" },
  { name: "docs", url: "https://yourapp.com/docs" },
  { name: "dashboard", url: "https://app.yourapp.com/dashboard" },
];

const MATRIX = [
  { label: "desktop-light",  darkMode: false, device: null },
  { label: "desktop-dark",   darkMode: true,  device: null },
  { label: "mobile-light",   darkMode: false, device: "iphone_14_pro" },
  { label: "mobile-dark",    darkMode: true,  device: "iphone_14_pro" },
  { label: "tablet-light",   darkMode: false, device: "ipad_pro_12_9" },
  { label: "tablet-dark",    darkMode: true,  device: "ipad_pro_12_9" },
];

async function main() {
  const outDir = "dark-mode-screenshots";
  await fs.mkdir(outDir, { recursive: true });

  for (const page of PAGES) {
    const pageDir = path.join(outDir, page.name);
    await fs.mkdir(pageDir, { recursive: true });

    console.log(`\n${page.name} (${page.url})`);

    // Run matrix in parallel
    await Promise.allSettled(
      MATRIX.map(async ({ label, darkMode, device }) => {
        try {
          const image = await captureScreenshot(page.url, {
            darkMode,
            ...(device && { viewportDevice: device }),
          });
          await fs.writeFile(path.join(pageDir, `${label}.png`), image);
          console.log(`  ✓ ${label}`);
        } catch (err) {
          console.error(`  ✗ ${label}: ${err.message}`);
        }
      })
    );
  }

  console.log("\nDone. Check dark-mode-screenshots/");
}

main();

This produces a directory structure like:

dark-mode-screenshots/
  home/
    desktop-light.png
    desktop-dark.png
    mobile-light.png
    mobile-dark.png
    tablet-light.png
    tablet-dark.png
  pricing/
    ...

Diff dark vs light to catch missed inversions

Flag pages where dark mode looks identical to light mode — meaning prefers-color-scheme: dark isn't being applied:

import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";

function areImagesIdentical(buf1, buf2, threshold = 0.01) {
  const img1 = PNG.sync.read(buf1);
  const img2 = PNG.sync.read(buf2);
  const { width, height } = img1;
  const diff = new PNG({ width, height });

  const changed = pixelmatch(img1.data, img2.data, diff.data, width, height, {
    threshold: 0.1,
  });

  return changed / (width * height) < threshold;
}

// Usage
const { light, dark } = await screenshotBothModes(url);
if (areImagesIdentical(light, dark)) {
  console.warn(`⚠️  Dark mode may not be applied on: ${url}`);
}

GitHub Actions on every PR

name: Dark mode visual check

on:
  pull_request:
    branches: [main]
    paths:
      - "src/**/*.css"
      - "src/**/*.scss"
      - "src/**/*.tsx"

jobs:
  dark-mode-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Wait for preview deployment
        run: |
          for i in $(seq 1 24); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ vars.PREVIEW_URL }}")
            [ "$STATUS" = "200" ] && break
            sleep 5
          done

      - name: Screenshot dark + light modes
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
          BASE_URL: ${{ vars.PREVIEW_URL }}
        run: node scripts/dark-mode-check.js

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        with:
          name: dark-mode-screenshots-${{ github.run_id }}
          path: dark-mode-screenshots/

      - name: Post to PR
        uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `## Dark mode screenshots\nLight/dark screenshots across desktop + mobile attached as artifacts on this run.`
            });

On-demand via CLI

# Screenshot a single URL in both modes
PAGEBOLT_API_KEY=your_key node -e "
const url = process.argv[1];
async function run() {
  const [light, dark] = await Promise.all([
    fetch('https://pagebolt.dev/api/v1/screenshot', {
      method: 'POST',
      headers: {'x-api-key': process.env.PAGEBOLT_API_KEY, 'Content-Type': 'application/json'},
      body: JSON.stringify({url, fullPage: true, blockBanners: true, darkMode: false})
    }).then(r => r.arrayBuffer()),
    fetch('https://pagebolt.dev/api/v1/screenshot', {
      method: 'POST',
      headers: {'x-api-key': process.env.PAGEBOLT_API_KEY, 'Content-Type': 'application/json'},
      body: JSON.stringify({url, fullPage: true, blockBanners: true, darkMode: true})
    }).then(r => r.arrayBuffer()),
  ]);
  require('fs').writeFileSync('light.png', Buffer.from(light));
  require('fs').writeFileSync('dark.png', Buffer.from(dark));
  console.log('Saved light.png and dark.png');
}
run();
" https://yourapp.com

Six screenshots per page (desktop light/dark, mobile light/dark, tablet light/dark) takes under 10 seconds. No browser to manage, no viewport config to maintain.

Get Started Free

100 requests/month, no credit card

Test dark mode rendering across desktop, mobile, and tablet automatically — catch missed inversions before users do. One darkMode: true flag, instant results.

Get Your Free API Key →