Dark mode screenshots
Set browser.dark_mode to true and the page renders with its dark theme, if it has one. This guide shows the request, explains what the option changes in the browser, and covers sites that ignore it. It is part of the guides hub.
Last verified on 2026-10-15 against the renderer source and a local Puppeteer 24.43.1 (Chrome 148) replay of the same browser calls.
What dark_mode changes
Before the page loads, the renderer sets the CSS media feature prefers-color-scheme:
browser.dark_mode |
prefers-color-scheme the page sees |
|---|---|
true |
dark |
false |
light |
| not set | the browser default |
Nothing else changes. The option does not invert colors, and it does not edit the page. A site that has no dark styles renders the same in both modes.
The third row matters for repeatable captures. In our local replay, a headless Chrome on a host set to a dark theme matched (prefers-color-scheme: dark) with no option set. If you need the light theme every time, send dark_mode: false instead of leaving it out.
The minimal request
GET, with the key in the Authorization header:
curl -sS --fail \ -H "Authorization: Bearer $RS_API_KEY" \ "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&dark_mode=true" \ -o dark.png
Send dark_mode=false to force the light theme. The GET parameter accepts only the strings true and false.
POST, with reduced motion as well, so that theme transitions and entrance animations do not show half-finished in the capture:
curl
curl -sS --fail -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "browser": { "dark_mode": true, "reduced_motion": true } }' \ -o dark.png
Node.js
import { writeFile } from 'node:fs/promises'; const body = { "url": "https://example.com", "browser": { "dark_mode": true, "reduced_motion": true } }; const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!res.ok) { console.error(res.status, await res.text()); process.exit(1); } await writeFile('dark.png', Buffer.from(await res.arrayBuffer()));
Python
import json import os import sys import urllib.error import urllib.request body = { 'url': 'https://example.com', 'browser': { 'dark_mode': True, 'reduced_motion': True, }, } req = urllib.request.Request( 'https://api.renderscreenshot.com/v1/screenshot', data=json.dumps(body).encode(), headers={ 'Authorization': f"Bearer {os.environ['RS_API_KEY']}", 'Content-Type': 'application/json', }, method='POST', ) try: with urllib.request.urlopen(req, timeout=90) as res: with open('dark.png', 'wb') as f: f.write(res.read()) except urllib.error.HTTPError as err: sys.exit(f'{err.code} {err.read().decode()}')
Ruby
require 'json' require 'net/http' body = { url: 'https://example.com', browser: { dark_mode: true, reduced_motion: true } } uri = URI('https://api.renderscreenshot.com/v1/screenshot') request = Net::HTTP::Post.new(uri) request['Authorization'] = "Bearer #{ENV.fetch('RS_API_KEY')}" request['Content-Type'] = 'application/json' request.body = body.to_json response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 90) do |http| http.request(request) end abort("#{response.code} #{response.body}") unless response.is_a?(Net::HTTPSuccess) File.binwrite('dark.png', response.body)
PHP
<?php $body = [ 'url' => 'https://example.com', 'browser' => [ 'dark_mode' => true, 'reduced_motion' => true, ], ]; $ch = curl_init('https://api.renderscreenshot.com/v1/screenshot'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('RS_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 90, ]); $image = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($image === false || $status !== 200) { fwrite(STDERR, $status . ' ' . ($image === false ? curl_error($ch) : $image) . "\n"); exit(1); } file_put_contents('dark.png', $image);
Go
package main import ( "bytes" "io" "log" "net/http" "os" ) const body = `{ "url": "https://example.com", "browser": { "dark_mode": true, "reduced_motion": true } }` func main() { req, err := http.NewRequest(http.MethodPost, "https://api.renderscreenshot.com/v1/screenshot", bytes.NewBufferString(body)) if err != nil { log.Fatal(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("RS_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() data, err := io.ReadAll(res.Body) if err != nil { log.Fatal(err) } if res.StatusCode != http.StatusOK { log.Fatalf("%d %s", res.StatusCode, data) } if err := os.WriteFile("dark.png", data, 0o644); err != nil { log.Fatal(err) } }
Java
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; public class Capture { public static void main(String[] args) throws Exception { String body = """ { "url": "https://example.com", "browser": { "dark_mode": true, "reduced_motion": true } } """; HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.renderscreenshot.com/v1/screenshot")) .header("Authorization", "Bearer " + System.getenv("RS_API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<byte[]> response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() != 200) { System.err.println(response.statusCode() + " " + new String(response.body())); System.exit(1); } Files.write(Path.of("dark.png"), response.body()); } }
To make a dark social card, add "preset": "og_card" to the same body. The preset sets a 1200x630 viewport. The dark_mode value you send stays as it is.
Sites that switch themes with a class
Many sites ignore prefers-color-scheme once a visitor picks a theme. They put a class such as dark on <html>, or a data-theme attribute, and read the choice from localStorage. The renderer starts with an empty profile, so those sites show their default theme.
Open the site in your browser, switch its theme and watch the <html> element in DevTools. The attribute that changes is the one to set. Then set it with a page script:
{ "url": "https://example.com", "browser": { "dark_mode": true }, "page": { "scripts": ["document.documentElement.classList.add('dark'); document.documentElement.dataset.theme = 'dark';"], "styles": ["*, *::before, *::after { transition: none !important; }"] } }
The order of the steps is why this works. The renderer loads the page and waits, then runs page.scripts, then injects page.styles, then captures. The script changes the theme after the site's own code has run, so the site cannot reset it. The style removes transitions right after the class change, and the CSS Transitions specification says that removing a running transition jumps the element to its end value. We replayed this order in Puppeteer on a test page with a 2-second background transition. 100 ms after the class change, the body color was rgb(242, 242, 242) without the style and rgb(0, 0, 0) with it.
dark_mode: true stays in the body. Some sites style a few parts, such as code blocks or embedded widgets, with the media query and the rest with the class.
The deep dive on dark mode screenshots with Puppeteer and Playwright shows the same detection work in your own browser code.
Parameters that matter
| Parameter | Type | Default | Notes |
|---|---|---|---|
browser.dark_mode |
boolean | not set | true = dark, false = light |
browser.reduced_motion |
boolean | false |
Sets prefers-reduced-motion: reduce |
page.scripts |
array | none | Runs after the wait step, before capture |
page.styles |
array | none | Injected after page.scripts |
wait.delay |
integer (ms) | 0 |
Runs before the page scripts, maximum 10,000 |
The full list is in the browser options reference and the page modifications reference.
Get both themes for one page
Send two requests that differ only in dark_mode. Each request has its own cache entry, because the cache key covers all rendering options, so the light and dark images never overwrite each other. Each fresh render uses one credit.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Light screenshot with dark_mode: true |
The site uses a class or data-theme |
Add the page script from this guide. |
| Dark screenshot with no option set | The default color scheme followed the host | Send dark_mode: false. |
| Text and background half-way between themes | A CSS transition was still running | Add the transition: none style and reduced_motion: true. |
| Dark page with a white block in it | An iframe or third-party widget has no dark theme | Hide it with page.hide, or accept it. Embeds do not follow your page's class. |
| Banding in dark gradients | JPEG compression on dark areas | Use PNG or WebP output. |
| Dark mode works but the logo disappears | A dark logo on a transparent background | The site needs a light logo for dark mode. Nothing in the request can fix it. |
Related
- Print media screenshots use the same kind of CSS emulation, for
@media print. - Custom User-Agent strings change what the server sends, not what CSS matches.
- Try a dark capture on your own site with a free account.