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
Top comments (0)