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 in this topic

Was this page helpful?