DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to test dark mode rendering across devices automatically

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());
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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/
    ...
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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.`
            });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)