Skip to main content

Command Palette

Search for a command to run...

How to test dark mode rendering across devices automatically

Published
4 min readView as Markdown

How to Test Dark Mode Rendering Across Devices Automatically

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

If you want to programmatically 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.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

More from this blog

P

Pagebolt.dev

401 posts