How to Take Screenshots in Dark Mode with Puppeteer
Use Puppeteer to emulate prefers-color-scheme: dark, wait for the page, and capture a reliable dark-mode screenshot.
Use page.emulateMediaFeatures() to set prefers-color-scheme to dark, then call page.screenshot(). Set the media feature before navigation so the page sees the preference from its first render.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: 'dark' },
]);
await page.goto('https://example.com', {
waitUntil: 'networkidle2',
});
await page.screenshot({
path: 'screenshot-dark.png',
fullPage: true,
});
} finally {
await browser.close();
}
Puppeteer documents Page.emulateMediaFeatures() for emulating CSS media features and Page.screenshot() for capturing the page.
1. Install Puppeteer
Create a project and install Puppeteer:
mkdir dark-mode-shot
cd dark-mode-shot
npm init -y
npm install puppeteer
Save the example as dark-shot.mjs and run it with node dark-shot.mjs. Puppeteer downloads a compatible browser during installation unless your environment is configured to use an existing executable.
2. Verify that dark mode is active
The emulation changes the browser’s CSS media feature. You can verify what the page detects before taking the screenshot:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: 'dark' },
]);
const detected = await page.evaluate(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches
);
console.log({ detected }); // { detected: true }
} finally {
await browser.close();
}
This confirms the browser-level preference. It does not prove that a particular application uses that preference for its theme.
3. Wait for the page to be ready
A dark screenshot can still be wrong if the page is captured while fonts, images, data, or a theme transition are loading. Pick a readiness condition that matches the site.
Wait for network activity to settle
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
networkidle2 waits for low network activity, but it is not a universal guarantee that every animation, font, or application request has finished.
Wait for a specific selector
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-page-ready]');
Wait for a fixed delay
await new Promise(resolve => setTimeout(resolve, 500));
Use a delay only when the page has a known transition or delayed render. A selector or application-ready signal is usually more stable.
Wait for fonts and images
await page.evaluate(async () => {
if (document.fonts?.ready) await document.fonts.ready;
await Promise.all(
Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(resolve => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
}))
);
});
4. Choose the capture area
Viewport screenshot
await page.screenshot({ path: 'viewport-dark.png' });
Full-page screenshot
await page.screenshot({
path: 'full-page-dark.png',
fullPage: true,
});
fullPage: true captures content beyond the visible viewport.
Specific element
const card = await page.waitForSelector('.pricing-card');
await card.screenshot({ path: 'card-dark.png' });
An element screenshot scrolls the element into view. It throws if the element is detached from the DOM, so select it after the page has rendered and avoid replacing it during capture.
Clipped region
await page.screenshot({
path: 'clip-dark.png',
clip: { x: 0, y: 0, width: 900, height: 600 },
});
5. Set viewport, device scale, and output format
await page.setViewport({
width: 1440,
height: 900,
deviceScaleFactor: 2,
});
await page.screenshot({
path: 'dark.webp',
type: 'webp',
quality: 85,
fullPage: true,
});
widthandheightcontrol the CSS viewport.deviceScaleFactorproduces higher-density output for the same CSS dimensions.typecan bepng,jpeg, orwebp. When writing to a path, Puppeteer can infer the type from the extension.qualityapplies to formats that support lossy quality settings, such as JPEG and WebP.omitBackground: truecaptures transparency where the page background is transparent.
await page.screenshot({
path: 'transparent-dark.png',
omitBackground: true,
});
6. Handle sites with their own theme toggle
prefers-color-scheme: dark only changes the emulated CSS media feature. A site may instead use a theme button, a cookie, local storage, or an account setting. In that case, set the application’s state as well.
Click a theme control
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.waitForSelector('[data-theme-toggle]');
await page.click('[data-theme-toggle]');
await page.waitForTimeout(200);
await page.screenshot({ path: 'app-dark.png', fullPage: true });
Set local storage before navigation
await page.goto('https://example.com');
await page.evaluate(() => {
localStorage.setItem('theme', 'dark');
});
await page.reload({ waitUntil: 'networkidle2' });
await page.screenshot({ path: 'stored-dark.png', fullPage: true });
The storage key is application-specific. Inspect the site’s code or use the same interaction a real visitor would use.
7. A reusable dark-mode capture script
import puppeteer from 'puppeteer';
const targetUrl = process.argv[2] ?? 'https://example.com';
const outputPath = process.argv[3] ?? 'screenshot-dark.png';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: 'dark' },
]);
await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 60_000 });
await page.evaluate(async () => {
if (document.fonts?.ready) await document.fonts.ready;
});
const isDark = await page.evaluate(() =>
matchMedia('(prefers-color-scheme: dark)').matches
);
if (!isDark) throw new Error('The page did not detect dark color preference');
await page.screenshot({ path: outputPath, fullPage: true });
console.log(`Saved ${outputPath}`);
} finally {
await browser.close();
}
Run it with node dark-shot.mjs https://example.com example-dark.png.
8. Common errors and fixes
| Symptom | Cause | Fix |
|---|---|---|
| The screenshot is still light | The site uses a custom theme state instead of the media feature. | Click its theme control or set its cookie/local-storage value, then reload. |
matchMedia reports false |
Emulation was not applied to that page, or it was overwritten. | Call emulateMediaFeatures on the page immediately before navigation and verify with matchMedia. |
| Dark colors flash, then change | A client-side theme transition runs after navigation. | Wait for a theme-ready selector, a known transition, or an application signal before capture. |
| Missing fonts or images | Capture happened before resources finished loading. | Wait for a page-specific ready selector and document.fonts.ready; handle image load errors explicitly. |
| Navigation timeout | The page keeps long-lived requests open or is slow. | Increase the timeout, use a suitable waitUntil value, and rely on a known readiness selector. |
| Element screenshot throws detached-node error | The framework replaced the element after selection. | Wait for rendering to settle and query the element again immediately before element.screenshot(). |
| Transparent output is opaque | The document or an ancestor paints a background. | Use omitBackground: true and remove the page’s background with CSS if appropriate. |
| Huge full-page file | Large dimensions, high device scale, or PNG compression. | Use WebP or JPEG where acceptable, reduce scale, or capture only the required element. |
9. Performance and reliability
- Launch one browser and reuse it for multiple pages or captures when your workload allows; browser startup is expensive compared with another page navigation.
- Reuse a page only when you reset cookies, storage, viewport, headers, and other state between targets.
- Prefer deterministic readiness signals over an unnecessarily long fixed delay.
- Use viewport or element captures when a full document is not required; full-page captures require more layout and image memory.
- Choose WebP or JPEG for smaller files when lossless PNG is unnecessary.
- Close the browser in a
finallyblock so failures do not leave processes running. - For repeatable output, pin your Puppeteer version, set the viewport explicitly, and avoid time-dependent content where possible.
10. Cost and operational considerations
Self-hosted Puppeteer costs the compute, memory, browser maintenance, and queueing infrastructure of the environment where it runs. Rendering a page can also trigger third-party requests and authentication or rate-limit behavior. Set navigation timeouts, limit concurrency to what the host can handle, and treat target URLs as untrusted input in a service that accepts arbitrary users.
11. Or skip the browser setup
ScreenshotNeo provides a screenshot API and MCP server. Its capture endpoint accepts a URL and returns PNG, JPEG, WebP, or PDF. The API can apply dark mode without maintaining your own browser process:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
See the ScreenshotNeo API documentation for the full option list, including dark mode, viewport and device presets, full-page and element capture, custom CSS and JavaScript, waits, headers, cookies, caching, async jobs, bulk capture, PDFs, and signed links.
- Cookie banners, newsletter popups, and chat widgets are removed before the shot.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are never billed; response headers identify the page verdict and whether it was billed.
- An MCP server lets AI agents use
take_screenshot,get_page_info, andcapture_pdf. - The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account and start with 1,000 screenshots a month at no charge.
12. FAQ
Does Puppeteer dark-mode emulation change the operating system theme?
No. It changes the page’s emulated CSS media feature for that Puppeteer page.
Should I call emulation before or after goto()?
Call it before navigation so the page sees the preference during its initial render.
Can I capture only one component in dark mode?
Yes. Wait for the component, then call its ElementHandle.screenshot() method.
Why does a dark-mode screenshot differ from what I see manually?
The site may use account state, local storage, cookies, viewport breakpoints, animations, or a custom theme toggle in addition to prefers-color-scheme.
Can I make a PDF in dark mode?
Puppeteer’s screenshot API creates images. For PDF output, use Puppeteer’s PDF workflow or a service such as ScreenshotNeo that supports PDF capture.


