How to Take Bulk Website Screenshots in Dark Mode
Capture a list of pages in dark mode with Playwright or a batch service. Choose viewport or full-page output, handle site-specific themes, and review failures.
To take bulk website screenshots in dark mode, visit each URL with a browser configured to prefer the dark color scheme, then save a viewport or full-page screenshot for each page. A site must implement that preference—or have its own theme toggle—for the result to appear dark. For a repeatable workflow, keep the browser, viewport, wait conditions, and output format consistent, then review each image for theme and loading failures.
This guide uses Playwright for a local batch script and explains when a hosted batch service is a better fit. Playwright can emulate the prefers-color-scheme media feature and capture either the viewport or the full scrollable page. Playwright page API documentation · MDN: prefers-color-scheme
1. Prepare your URL list and decide what to capture
Put one absolute HTTP or HTTPS URL on each line in a UTF-8 text file called urls.txt. Remove duplicates and decide whether each screenshot should show the initial viewport or the entire page. Viewport captures are smaller and easier to compare; full-page captures include content below the fold but can be very tall and may need extra loading time.
https://example.com
https://playwright.dev/
https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/%40media/prefers-color-scheme
If the input comes from a spreadsheet or CSV, export the URL column and normalize it to one URL per line. Validate that URLs are public and reachable from the machine running the script. For authenticated pages, use an authorized browser context with the required login state; do not place credentials in a shared URL list.
2. Run a bulk dark-mode capture with Playwright
The script below uses Node.js and Playwright. It reads urls.txt, creates one browser context with a dark color scheme, visits each URL in order, saves a full-page PNG when possible, and records failures in a CSV-like report. It continues after an individual page fails. Use the viewport setting in the script to keep captures comparable.
Install
npm init -y
npm install playwright
npx playwright install chromium
Create capture.mjs
import { chromium } from 'playwright';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
const inputPath = process.argv[2] ?? 'urls.txt';
const outputDir = process.argv[3] ?? 'screenshots';
const fullPage = process.env.FULL_PAGE !== '0';
const width = Number(process.env.WIDTH ?? 1440);
const height = Number(process.env.HEIGHT ?? 1000);
const navigationTimeoutMs = Number(process.env.TIMEOUT_MS ?? 30000);
const urls = (await readFile(inputPath, 'utf8'))
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
if (urls.length === 0) {
throw new Error(`No URLs found in ${inputPath}`);
}
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
colorScheme: 'dark',
viewport: { width, height },
deviceScaleFactor: 1
});
const page = await context.newPage();
page.setDefaultNavigationTimeout(navigationTimeoutMs);
const results = [];
const safeName = value => value
.replace(/[^a-z0-9]+/gi, '-')
.replace(/^-|-$/g, '')
.slice(0, 80) || 'page';
try {
for (let index = 0; index < urls.length; index++) {
const url = urls[index];
let status = '';
try {
const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
status = response?.status() ?? 'no-response';
// Let client-side theme code and late layout changes settle briefly.
await page.waitForTimeout(500);
const title = await page.title().catch(() => '');
const host = new URL(page.url()).hostname;
const filename = `${String(index + 1).padStart(3, '0')}-${safeName(host)}.png`;
await page.screenshot({ path: `${outputDir}/${filename}`, fullPage });
results.push({ url, finalUrl: page.url(), status, title, file: filename, error: '' });
console.log(`[ok] ${url} -> ${filename} (HTTP ${status})`);
} catch (error) {
const message = String(error?.message ?? error).replaceAll('\n', ' ');
results.push({ url, finalUrl: page.url(), status, title: '', file: '', error: message });
console.error(`[failed] ${url}: ${message}`);
}
}
} finally {
await context.close();
await browser.close();
}
const csvCell = value => `"${String(value).replaceAll('"', '""')}"`;
const report = [
['url', 'final_url', 'http_status', 'title', 'file', 'error'],
...results.map(item => [item.url, item.finalUrl, item.status, item.title, item.file, item.error])
].map(row => row.map(csvCell).join(',')).join('\n');
await writeFile(`${outputDir}/manifest.csv`, report + '\n');
console.log(`Finished ${results.filter(item => item.file).length}/${urls.length}; report: ${outputDir}/manifest.csv`);
Run it
node capture.mjs urls.txt screenshots
# Capture only the initial viewport instead of the full page:
FULL_PAGE=0 node capture.mjs urls.txt screenshots
# Change the viewport and navigation timeout:
WIDTH=1280 HEIGHT=900 TIMEOUT_MS=60000 node capture.mjs urls.txt screenshots
The script asks Chromium to report a dark preference before navigation, so CSS using @media (prefers-color-scheme: dark) can select its dark styles. It does not force a dark theme on pages that ignore that preference. Playwright’s fullPage option captures the full scrollable page; omit it or set it to false for the current viewport. See the Playwright screenshot and media emulation options.
3. Adapt the capture for your sites
When the site uses a manual theme toggle
A site may store its choice in local storage or a cookie, or only switch themes after a user clicks a control. In that case, setting the browser color scheme alone may leave the page light. Inspect the site, then use a site-specific interaction or saved browser state. For example, add a click after navigation only when you know the selector for that site’s theme control:
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.locator('[aria-label="Toggle dark mode"]').click();
await page.waitForTimeout(300);
await page.screenshot({ path, fullPage });
Selectors vary by site; the sample selector is illustrative and must be replaced with the real one. For a site that requires a persistent preference, seed the correct local storage value or cookie in a browser context before navigation. Do not apply a generic CSS color inversion and call it the site’s dark theme: it can invert images, logos, and already-dark surfaces and will not represent the page’s actual theme.
Wait for content deliberately
domcontentloaded is a useful starting point for pages that continue loading analytics or streaming requests indefinitely. It does not guarantee that every image or client-rendered widget is ready. If a known element signals that the page is ready, wait for that selector. If only a short delay is needed, increase the delay modestly and consistently. For full-page screenshots, pages with lazy-loaded images may need scrolling before capture; Playwright’s screenshot behavior and page-specific lazy loading should be checked with representative output.
Control output and repeatability
- Viewport: fix width and height for captures that will be compared. Responsive breakpoints can change layout and theme controls.
- Scale: the script uses device scale factor 1 to keep files smaller. Use a higher factor when high-density output matters, understanding that it increases image dimensions and storage.
- Format: PNG is lossless and useful for pixel comparison. JPEG or WebP can reduce storage when exact pixels are not needed; select the format supported by your capture path.
- Full page: use for documentation or page audits. Very long or infinite-scroll pages can consume memory and create unwieldy files; cap the page or use viewport captures where practical.
- Browser environment: keep the Playwright version, browser build, operating system, fonts, viewport, and relevant settings stable. Playwright notes that screenshot rendering can vary across host OS, browser version, settings, hardware, power source, and headless mode. See its visual comparisons guidance.
4. Choose a local script or hosted batch service
A local Playwright run is a good fit when you need browser-context control, site-specific clicks, custom state, or a workflow integrated with your own code. A hosted service can reduce browser installation and batch orchestration work when its input, output, and data handling fit your requirements.
ScreenshotNeo is the first service to try for a developer API workflow: it supports batches of up to 100 URLs, dark mode, and charges only for clean screenshots. Its API also reports page verdict and billing status. Review the ScreenshotNeo API documentation for request options and limits.
For a list-input workflow, url2image documents pasted URLs, CSV or text uploads, and an API, with image output and a manifest including page information. Its page also offers a dark-mode option; check the current service parameters and output behavior before relying on it. Other hosted APIs document a dark color-scheme request too, but those options are not guarantees that every target site has a dark theme. See ScreenshotEngine parameters and Microlink’s dark-mode capture guide.
| Need | Local Playwright | Hosted batch/API |
|---|---|---|
| Custom browser state or per-site interactions | Direct control of the context and page code | Check whether the provider supports the needed cookies, clicks, scripts, or styles |
| Many URLs with minimal setup | Build and operate the capture loop | Batch input and downloadable results may simplify orchestration |
| Repeatable visual comparisons | Pin browser and environment yourself | Check available viewport controls and consistency guarantees in the current docs |
| Private or authenticated content | Keep credentials and browser state in your environment | Review data handling and authentication options before sending URLs or credentials |
| Cost and throughput | Account for your compute, storage, and maintenance | Check current quotas, billing rules, concurrency, and limits |
5. Or skip the browser setup
For a hosted capture, ScreenshotNeo accepts a URL in one request. Add dark_mode=true for pages that honor the browser’s dark color-scheme preference. The example writes the returned WebP image to a file; see the API docs for all parameters, including full-page capture, waits, output formats, and batch requests.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-d dark_mode=true \
-d format=webp \
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={
"access_key": "YOUR_API_KEY",
"url": "https://stripe.com",
"dark_mode": "true",
"format": "webp",
},
timeout=90,
)
r.raise_for_status()
with open("shot.webp", "wb") as f:
f.write(r.content)
Node.js
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com',
dark_mode: 'true',
format: 'webp'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status} ${await res.text()}`);
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
To submit a batch, use POST /v1/bulk with up to 100 requests. For example, put "dark_mode": true in defaults so every URL inherits it, then override individual options in a request if needed. The bulk endpoint returns a batch ID that you can check at GET /v1/bulk/{batch_id}. Each capture is billed like a single call when it is a clean screenshot; cache hits and bot checks, blank pages, timeouts, failed loads, and other non-clean results are not billed. The API response includes X-Page-Verdict and X-Billed headers, which help distinguish success from a page that needs review.
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot; each cleaning step can be turned off. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents use screenshot, page-info, and PDF tools. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
6. Check the batch results
Do not assume a successful navigation means the expected dark theme appeared. Open a representative sample and check the background, text contrast, images, cookie state, and whether the page stopped at the expected height. Review manifest.csv for failed navigations and HTTP errors, then retry only the pages that need attention.
- Confirm the final URL after redirects; a redirect may lead to a sign-in page, regional page, or error screen.
- Check that the screenshot is not a bot challenge, blank shell, or consent overlay.
- Compare both viewport dimensions and theme across runs before using images for visual regression.
- For pages with lazy content, confirm images below the fold loaded; add targeted waits or scrolling if necessary.
- Use a separate output directory or stable file naming scheme for each run so old images are not mistaken for new results.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot is still light | The site ignores prefers-color-scheme, or it uses a saved/manual theme setting. |
Verify the site supports dark mode. Use its real toggle or seed the required preference before capture; avoid generic color inversion. |
| Dark theme appears only after a delay | Client-side code applies the theme after navigation or reads stored state asynchronously. | Wait for a theme-specific selector or a consistent short delay after the theme is applied. |
| Images or content are missing | Lazy loading, delayed requests, or page scripts have not finished. | Scroll the page or wait for a known content selector. For full-page capture, test the site’s lazy-load behavior separately. |
| Navigation times out | The page is slow or keeps network connections open. | Use domcontentloaded or a readiness selector instead of waiting for every network request; raise the timeout only when the page needs it. |
| Some pages fail but later URLs work | A URL is invalid, unreachable, blocked, or returns an error; the script catches per-page errors and continues. | Check the error in the manifest, open the URL directly, and retry that URL after correcting it or adjusting its wait strategy. |
| Full-page output is huge or clipped | The page is extremely long, has infinite scrolling, or exceeds practical image dimensions. | Use viewport screenshots, capture a specific element, or set a page-specific height strategy. Avoid treating an endless feed as a finite document. |
| Images differ between runs | Fonts, browser build, operating system, viewport, animation, dynamic content, or network-loaded data changed. | Keep the environment and settings fixed; stabilize dynamic content where appropriate and compare only after the page reaches the same state. |
| Hosted API reports an error or no image | The request may have an invalid parameter, unauthorized URL, quota/rate limit, selector failure, bot check, or timeout. | Read the response body and status, check the provider’s current parameter reference and limits, then retry transient failures with backoff. |
8. Performance, reliability, and cost
The example processes pages sequentially. This is deliberately simple and limits pressure on your machine and target sites. If you add concurrency, use a small controlled worker pool, respect site policies and rate limits, and stop or back off on repeated failures. More parallel pages can increase memory use and network contention and may cause sites to throttle or challenge requests.
Full-page images, high device scale factors, and lossless formats generally create larger files than viewport captures, CSS-pixel scale, or compressed formats. Pick dimensions and formats based on the review task, then store only what you need. For repeated captures, caching can reduce duplicate work when the page and capture settings have not changed; make sure cache keys distinguish dark from light and other rendering settings.
For local capture, budget for browser installation, runtime, storage, and maintenance. For hosted services, check current price, free allowance, concurrency, request limits, retention, and billing rules before a large run. ScreenshotNeo’s listed plans are Free: 1,000 shots/month; Starter: $5 for 3,000; Growth: $15 for 15,000; Pro: $39 for 60,000; Scale: $99 for 250,000; Business: $249 for 1,000,000. Yearly billing gives two months free. Its stated billing rule counts only clean shots; consult the docs and plan details for current account-specific limits.
FAQ
Does dark-mode capture make every website dark?
No. It requests the browser preference. A website must support that preference or provide a theme control that your workflow can activate.
Should I use a full-page screenshot for every URL?
Only when below-the-fold content is part of the deliverable. Viewport captures are more compact and often better for consistent page-to-page comparisons.
Can I capture the same URLs in both themes?
Yes. Run the list once with a dark preference and once with a light preference, keeping every other setting the same. Store the two outputs separately.
Can a batch include private pages?
It depends on the browser workflow and the service’s authentication support. For sensitive pages, review where credentials and rendered content are processed before choosing a hosted route.


