Dark mode screenshots in Puppeteer, Playwright, Selenium & API

Emulating dark mode is one line in every headless browser. Where you put that line decides whether the screenshot comes out dark, light, or half-faded. We measured it in Puppeteer 24, Playwright and Selenium on a test page with the three common ways sites switch themes, then on real sites.

Why the screenshot comes out light

The test page uses three theming patterns at once: a CSS @media (prefers-color-scheme: dark) rule, an html.dark class set by a small script before first paint (the Tailwind darkMode: 'class' / next-themes pattern), and a <picture> with a dark-only <source media>. Here is what each approach turned dark:

How dark mode was setResult
Puppeteer emulateMediaFeatures after goto
Playwright page.emulateMedia after goto: same
CSS and picture dark, class-based theme stays light
Puppeteer emulateMediaFeatures before gotoAll three dark
Playwright newContext({ colorScheme: "dark" })All three dark
Before goto, but the site saved theme=light in localStorageClass-based theme stays light

The class-based script reads matchMedia once, while the page loads. If you emulate dark mode after goto, that moment has passed. Some sites also listen for changes (react.dev and nextjs.org both switched when we emulated after load), but you can't tell from outside which kind you have. So set the color scheme before navigating.

Switching after load has a second problem: the page re-themes while you capture it. On a page with transition: background-color .5s, a Playwright screenshot taken right after page.emulateMedia({ colorScheme: "dark" }) came out rgb(255,255,255) on one run and rgb(251,251,251) on the next. With the context option it was rgb(17,17,17) both times.

Puppeteer

import puppeteer from "puppeteer";

const browser = await puppeteer.launch({ args: ["--no-sandbox"] });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });

// Before goto: scripts that read the theme once at load see "dark" too
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]);
await page.goto("https://developer.mozilla.org/en-US/", { waitUntil: "networkidle2" });

await page.screenshot({ path: "dark.png" });
await browser.close();

Save it as dark.mjs. --no-sandbox is needed for Chrome on Ubuntu 23.10+ and in most containers.

Playwright (Node.js)

import { chromium } from "playwright";

const browser = await chromium.launch();
// colorScheme on the context applies before the first byte of the page loads
const context = await browser.newContext({ colorScheme: "dark", viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
await page.goto("https://developer.mozilla.org/en-US/");

await page.screenshot({ path: "dark.png" });
await browser.close();

Every page opened from this context starts in dark mode. Use page.emulateMedia({ colorScheme: "dark" }) only to flip a page that is already open, and then wait for transitions before capturing.

Playwright (Python)

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(color_scheme="dark", viewport={"width": 1280, "height": 800})
    page.goto("https://developer.mozilla.org/en-US/")
    page.screenshot(path="dark.png")
    browser.close()

new_page() takes the same options as new_context() (color_scheme, viewport, device_scale_factor), so this is the one-line version of the above.

Selenium (Python, Chrome)

from selenium import webdriver

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1280,800")
driver = webdriver.Chrome(options=options)

# Chrome only: set the media feature over DevTools before loading the page
driver.execute_cdp_cmd("Emulation.setEmulatedMedia", {
    "features": [{"name": "prefers-color-scheme", "value": "dark"}],
})
driver.get("https://developer.mozilla.org/en-US/")
driver.save_screenshot("dark.png")
driver.quit()

Selenium has no color-scheme option of its own. In Chrome, the DevTools Emulation.setEmulatedMedia command does the job, and it has to run before driver.get() for the same reason as above. Note that --window-size=1280,800 sets the outer window size, so our screenshot was 1280×657.

Sites that remember a theme

If the site has a theme toggle, it usually saves the choice in localStorage, and a saved choice wins over the system setting. On our test page, a saved theme=light kept the page light under emulated dark mode. Fresh browser contexts start with empty storage, so this only happens when you reuse a profile or a storageState file (for example, a logged-in session). The fix is to write the value before any page script runs:

import { chromium } from "playwright";

const browser = await chromium.launch();
const context = await browser.newContext({ colorScheme: "dark" });
// Runs before any page script: overrides a theme the site saved earlier.
// "theme" is the key next-themes uses; check DevTools > Application > Local Storage for yours.
await context.addInitScript(() => localStorage.setItem("theme", "dark"));
const page = await context.newPage();
await page.goto("https://your-app.example.com/");

await page.screenshot({ path: "dark.png" });
await browser.close();

We tested this with a saved theme=light in storageState: the page rendered dark with the init script and light without it. The key name and value depend on the site.

What emulation can't change

The same thing with one API call

Shotmint's dark_mode=true sets the color scheme on the browser context before the page loads. On the test page (sent as html=), the CSS and class-based boxes both came out dark.

curl -o dark.png -H "X-API-Key: $SHOTMINT_KEY" \
  "https://shotmint.creasework.com/v1/screenshot?url=https://developer.mozilla.org/en-US/&dark_mode=true&width=1280&height=800"

Full page, with MDN's light ad banner hidden (Node 18+, ESM):

import { writeFile } from "node:fs/promises";

const params = new URLSearchParams({
  url: "https://developer.mozilla.org/en-US/",
  dark_mode: "true",
  full_page: "true",
  hide_selectors: "mdn-placement-top", // MDN's ad banner is light in both themes
});
const res = await fetch(`https://shotmint.creasework.com/v1/screenshot?${params}`, {
  headers: { "X-API-Key": process.env.SHOTMINT_KEY },
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("dark-full.png", Buffer.from(await res.arrayBuffer()));

That run returned a 1280×3232 PNG, dark from top to bottom. Other options combine with it: device_scale_factor=2 for retina, selector for one element, mobile=true, and wait_until/delay for slow pages. See the docs, or try it without a key on the full-page screenshot tool.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: Headless browser how-tos

All guides: guides · integrations · comparisons