Needed to generate both light and dark mode screenshots of a marketing site for a client's brand guidelines doc. Turns out forcing dark mode in a headless browser isn't as straightforward as setting a CSS class.
Here's how I got it working with Playwright (same approach works with Puppeteer).
The prefers-color-scheme approach
Most modern sites use the prefers-color-scheme media query for dark mode. You can emulate this in Playwright:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
// Light mode capture
const lightCtx = await browser.newContext({
colorScheme: 'light',
viewport: { width: 1280, height: 800 }
});
const lightPage = await lightCtx.newPage();
await lightPage.goto('https://example.com');
await lightPage.screenshot({ path: 'light.png', fullPage: true });
// Dark mode capture
const darkCtx = await browser.newContext({
colorScheme: 'dark',
viewport: { width: 1280, height: 800 }
});
const darkPage = await darkCtx.newPage();
await darkPage.goto('https://example.com');
await darkPage.screenshot({ path: 'dark.png', fullPage: true });
await browser.close();
})();
The key is colorScheme: 'dark' in the browser context. This sets prefers-color-scheme: dark at the OS level, so the page responds exactly as it would on a user's machine with dark mode enabled.
When the site uses a toggle instead
Some sites don't rely on the media query. They use a JavaScript toggle that sets a class on <body> or <html> — something like class="dark" or data-theme="dark".
For these, you need to interact with the page:
const page = await browser.newPage();
await page.goto('https://example.com');
// Take light mode screenshot first
await page.screenshot({ path: 'light.png' });
// Click the theme toggle
await page.click('[data-testid="theme-toggle"]');
// Wait for transition animations to finish
await page.waitForTimeout(500);
await page.screenshot({ path: 'dark.png' });
If you don't know the toggle's selector, you can force it by injecting the class directly:
await page.evaluate(() => {
document.documentElement.classList.add('dark');
// or
document.documentElement.setAttribute('data-theme', 'dark');
});
// Give CSS transitions time to complete
await page.waitForTimeout(300);
await page.screenshot({ path: 'dark-forced.png' });
This is hacky but reliable when you can't find the toggle button.
Handling both approaches at once
Real-world sites sometimes use a combination — media query as default, with a manual override stored in localStorage. Here's what I ended up with for a batch capture script:
async function captureWithTheme(url, theme, outputPath) {
const context = await browser.newContext({
colorScheme: theme,
viewport: { width: 1280, height: 800 }
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
// Also set common localStorage/cookie theme preferences
await page.evaluate((t) => {
localStorage.setItem('theme', t);
localStorage.setItem('color-mode', t);
document.cookie = `theme=${t}; path=/`;
}, theme);
// Reload to pick up localStorage-based themes
await page.reload({ waitUntil: 'networkidle' });
// Force the class as a fallback
await page.evaluate((t) => {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(t);
document.documentElement.setAttribute('data-theme', t);
}, theme);
await page.waitForTimeout(500);
await page.screenshot({ path: outputPath, fullPage: true });
await context.close();
}
// Usage
await captureWithTheme('https://example.com', 'dark', 'dark.png');
await captureWithTheme('https://example.com', 'light', 'light.png');
Bit aggressive with the overrides, but it catches most theme implementations I've encountered.
The Puppeteer equivalent
If you're using Puppeteer instead of Playwright, the media query emulation looks different:
const page = await browser.newPage();
// Emulate dark mode
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: 'dark' }
]);
await page.goto('https://example.com');
await page.screenshot({ path: 'dark.png' });
emulateMediaFeatures works well but you need to call it before navigating to the page. If you call it after, some components might not re-render until you force a repaint.
Gotchas I ran into
CSS transitions mess up screenshots. If the site has a smooth dark-to-light transition (0.3s fade), your screenshot might catch it mid-transition. Either wait for the animation to complete or disable transitions entirely:
await page.addStyleTag({
content: '*, *::before, *::after { transition: none !important; animation: none !important; }'
});
Images don't always switch. Some sites serve different image assets for light/dark mode using the <picture> element with prefers-color-scheme in the <source> media attribute. The colorScheme context option handles this correctly. The JavaScript class-forcing approach does not — you'll get light mode images with a dark background.
Third-party widgets ignore your theme. Embedded chat widgets, analytics consent banners, and social embeds use their own theme logic. Your dark mode screenshot might have a blinding white cookie popup in the corner. Remove these elements before capturing:
await page.evaluate(() => {
const selectors = ['[class*="cookie"]', '[class*="consent"]',
'[class*="chat-widget"]', 'iframe[src*="chatbot"]'];
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => el.remove());
});
});
System dark mode affects more than colors. On macOS, dark mode also changes scrollbar appearance, form control styling, and the default background color of transparent elements. If pixel-perfect consistency between light and dark captures matters, set a fixed background on <html> before capturing.
When to use this
Visual regression testing is the obvious case — you want to verify that both themes render correctly after every deploy. But I've also used it for generating marketing assets (light mode for print, dark mode for social media previews) and documentation screenshots where you want to show both variants side by side.
The Playwright approach with colorScheme in the browser context is the cleanest solution for media-query-based themes. For everything else, the JavaScript override approach gets the job done even if it feels a bit brute-force.
Top comments (0)