A landing page gallery is only as good as its screenshots. Visitors come to study whole pages: the hero, the section rhythm, the pricing table, the footer. They want the phone layout next to the desktop one, and the dark theme when there is one. A gallery of first-screen crops with cookie dialogs over half of them teaches nobody anything. Capture three views per site in one batch (desktop full page, mobile full page and a dark-mode first screen), block cookie banners and chat widgets, make the grid tile from the top of the desktop capture, and keep the dark view only when the site really has one.
This page extends the website thumbnails pattern from one small preview to a full set of views per site.
The request gallery
Every site in the gallery gets the same three requests. Shared options: "output": {"format": "png"}, "block": {"cookie_banners": true, "ads": true, "chat_widgets": true} and "wait": {"until": "networkidle", "timeout": 30}.
| View | Request options | What it shows |
|---|---|---|
| Desktop, full page | "viewport": {"width": 1440, "height": 900}, "capture": {"mode": "full_page", "scroll": {"simulate": true, "max_height": 12000}} |
The whole page at a common laptop width. Scrolling first loads lazy images |
| Mobile, full page | "device": "iphone_14_pro", same capture |
The phone layout at device scale 3, with a mobile user agent |
| Dark mode, first screen | "viewport": {"width": 1440, "height": 900}, "browser": {"dark_mode": true} |
The dark theme, if the site has one |
| Grid tile | No request. Cropped from the desktop capture | The hero at 720x450 |
Why PNG for all three? The files are masters you convert later, and a very long mobile page at scale 3 can pass WebP's limit of 16,383 pixels per side. The full-page guide explains scrolling and height limits, and the dark mode guide explains what dark_mode changes.
How it works
flowchart LR
L[Site list] -->|3 sites x 3 views<br/>= 9 requests| B[POST /v1/batch]
B -->|results by position| S[Script]
S -->|download image_url| S
S --> T[Top crop: tile 720x450]
S --> F[Desktop and mobile<br/>WebP, or PNG if too tall]
S --> D{Dark capture<br/>clearly darker?}
D -->|yes| K[Keep dark.webp]
D -->|no| X[Discard]
T & F & K --> G[(Your storage)] --> W[Gallery]
The batch stays synchronous because 9 requests is under the 10-item threshold, so the script gets every CDN link in one response. Results carry a position, which maps each one back to its site and view without guessing from the URL.
The script
// gallery.mjs: desktop full page, mobile full page and dark mode for each site, plus a grid tile. // Usage: RS_API_KEY=rs_live_... node gallery.mjs https://example.com https://example.org import { mkdir, writeFile } from 'node:fs/promises'; import sharp from 'sharp'; const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'; const SITES_PER_BATCH = 3; // 3 captures each = 9 requests: synchronous (10 or fewer) and within every paid plan's batch size const COMMON = { output: { format: 'png' }, block: { cookie_banners: true, ads: true, chat_widgets: true }, wait: { until: 'networkidle', timeout: 30 }, }; const FULL = { mode: 'full_page', scroll: { simulate: true, max_height: 12000 } }; const CAPTURES = { desktop: { viewport: { width: 1440, height: 900 }, capture: FULL }, mobile: { device: 'iphone_14_pro', capture: FULL }, dark: { viewport: { width: 1440, height: 900 }, browser: { dark_mode: true } }, }; async function meanLuma(input) { const { channels } = await sharp(input).greyscale().stats(); return channels[0].mean; // 0 = black, 255 = white } // WebP stops at 16383 px per side. A long mobile page at device scale 3 passes that, so keep it as PNG. export async function saveImage(buf, base) { const { height } = await sharp(buf).metadata(); if (height > 16383) return writeFile(`${base}.png`, buf); return sharp(buf).webp({ quality: 80 }).toFile(`${base}.webp`); } async function processSite(dir, files) { // Grid tile: the top of the desktop page. Cropping in sharp keeps the header; a 'cover' // resize gives no control over which part of a tall page survives the crop. const top = await sharp(files.desktop).extract({ left: 0, top: 0, width: 1440, height: 900 }).toBuffer(); await sharp(top).resize(720, 450).webp({ quality: 80 }).toFile(`${dir}/tile.webp`); await saveImage(files.desktop, `${dir}/desktop`); await saveImage(files.mobile, `${dir}/mobile`); // Sites without a dark theme return the light design. Keep the dark capture only if it is darker. const hasDark = (await meanLuma(top)) - (await meanLuma(files.dark)) > 40; if (hasDark) await saveImage(files.dark, `${dir}/dark`); return hasDark; } export async function captureGallery(sites, outDir = 'gallery') { for (let i = 0; i < sites.length; i += SITES_PER_BATCH) { const chunk = sites.slice(i, i + SITES_PER_BATCH); const requests = chunk.flatMap((url) => Object.entries(CAPTURES).map(([, opts]) => ({ url, ...COMMON, ...opts }))); const res = await fetch(`${API}/v1/batch`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ requests }), signal: AbortSignal.timeout(180_000), }); const batch = await res.json(); if (!res.ok) throw new Error(`Batch failed: HTTP ${res.status} ${batch.error?.code}`); const kinds = Object.keys(CAPTURES); for (const [s, url] of chunk.entries()) { const items = kinds.map((_, k) => batch.results.find((r) => r.position === s * kinds.length + k)); if (items.some((r) => r?.status !== 'completed')) { console.error(`skipped ${url}: ${items.map((r) => r?.error).filter(Boolean).join('; ')}`); continue; } const files = {}; for (const [k, item] of items.entries()) { const img = await fetch(item.image.image_url); if (!img.ok) throw new Error(`Download failed: ${img.status}`); files[kinds[k]] = Buffer.from(await img.arrayBuffer()); } const dir = `${outDir}/${new URL(url).hostname}`; await mkdir(dir, { recursive: true }); const hasDark = await processSite(dir, files); await writeFile(`${dir}/meta.json`, JSON.stringify({ url, hasDark, capturedAt: new Date().toISOString() })); console.log(`ok ${url}${hasDark ? ' (+dark)' : ''}`); } } } if (import.meta.url === `file://${process.argv[1]}`) { try { await captureGallery(process.argv.slice(2)); } catch (err) { console.error(err.message); process.exitCode = 1; } }
Three parts of this script answer the questions galleries run into:
- The tile. The API's
output.resizewithfit: "cover"fills the box and crops the overflow, and the request cannot say which part of a 9,000-pixel page to keep. The script cuts the top 1440x900 in sharp and scales that, so every tile shows the hero. - The dark check.
browser.dark_modeasks for the dark scheme, but a site without dark styles returns its light design. The script compares the mean brightness of the dark capture with the top of the light one and keeps the dark file only when the gap is large. The threshold of 40 on a 0 to 255 scale is a starting point, not a measured value. Tune it on your own list. - The size guard. sharp refuses to write WebP taller than 16,383 pixels (
Processed image is too large for the WebP format), so those captures stay PNG.
Tested on 2026-10-18 with Node.js 22.18.0 and sharp 0.35.5. The batch call ran against a local stub that returns the documented batch shape, with generated light and dark images in place of real renders. Four sites produced tiles at 720x450 and dark files where the dark image was darker. A site without a dark theme produced no dark file, and a 17,000-pixel capture was saved as PNG. No real site was rendered for this page.
Batch size and plans
Each batch holds up to your plan's batch size, and the free plan's cap is the smallest. SITES_PER_BATCH = 3 sends 9 items, which fits every paid plan. On Free, set it to 1, which sends 3 items. Larger batches work too, but more than 10 items makes the batch asynchronous, so you would poll GET /v1/batch/:id or add a webhook. The batch reference covers both. Cookie banners that slip past block.cookie_banners can be clicked away with page.click; the blocking guide lists the options.
Keeping the gallery honest
Design galleries age fast. Store capturedAt (the script writes it to meta.json) and show it on each entry, so visitors know whether they are looking at this year's design. Re-capture featured sites every quarter, and drop entries whose capture now fails, because a site that no longer loads is no longer inspiration.
If visitors submit sites, run the capture from the submission flow on the directory websites page and add the two extra views there.
What it costs
Three credits per site, and nothing for failed captures. A gallery that adds 300 sites a month uses 900 renders, inside the Starter plan's 2,500 credits for $10 a month. Re-capturing 1,000 featured sites each quarter adds 3,000 renders in that month, which suits Growth at $40 for 12,000 credits. Full plan details are on the pricing page.
Next steps
Get a free API key, set SITES_PER_BATCH to 1 for the free plan, and run the script on five sites you admire.