Website Design Audit Screenshots on One Board

Capture every page of a site on desktop, tablet and phone, in light and dark mode, and lay the screenshots out on one HTML audit board for a design review.

By Simon Chiu · Updated

A design review starts with the same chore every time: open each page on a laptop, then on a phone, then switch the system theme and do it again, taking screenshots by hand and pasting them into a slide. By the time the slide is done, the screenshots are out of date. Give a script the list of pages, let it capture each one in five variants (desktop light and dark, tablet, phone light and dark), and write one HTML board with a row per page and a column per variant. A reviewer reads across a row to compare one page between devices, and down a column to compare pages on one device. This page has the variants, the script and the settings.

It is one of the use cases for product and design teams. If you need screenshots for a help center instead of a review, read documentation screenshots.

What the board shows that a slide does not

  • The whole page. Each cell is a full-page capture, so a broken footer on the phone layout is on the board, not three scrolls away.
  • Consistency across pages. A column of headers on one device shows at once which page uses an old button style or a different spacing scale.
  • Dark mode gaps. The dark column shows which pages have no dark styles, a light image in a dark card, or text that becomes unreadable. The dark mode guide explains what the option changes.
  • A date. The board states when it was captured, so a finding refers to a known state of the site.

The variants

Each variant is the part of a POST /v1/screenshot body that changes between columns. Every request also sends the same capture, output, block and wait settings, listed below the table.

Column Body Why this column
Desktop, light "viewport": {"width": 1440, "height": 900}, "browser": {"dark_mode": false} A common laptop width, light theme forced so the result does not depend on a default
Desktop, dark "viewport": {"width": 1440, "height": 900}, "browser": {"dark_mode": true} Same layout, dark theme
iPad Air "device": "ipad_air", "viewport": {"scale": 1} The tablet breakpoint, where many layouts fall between two designs
iPhone 14 "device": "iphone_14", "viewport": {"scale": 1}, "browser": {"dark_mode": false} The phone layout with a mobile user agent and touch
iPhone 14, dark "device": "iphone_14", "viewport": {"scale": 1}, "browser": {"dark_mode": true} Dark theme on the phone, where contrast problems are worst

Shared settings: capture.mode: "full_page" with scroll.simulate: true so lazy images load, WebP at quality 85, blocked cookie banners and chat widgets, and wait.until: "networkidle". A device sets the viewport size, scale, mobile mode, touch and user agent; explicit viewport values win, which is how scale: 1 overrides the device's own ratio. The devices reference lists all fifteen devices and their sizes, if your audience uses other screens.

How it works

flowchart LR
  L[List of page paths] --> S[audit.mjs]
  S -->|5 POST requests per page<br/>Accept: application/json| R[RenderScreenshot]
  R -->|image.url on the CDN<br/>metadata.title| S
  S --> B[audit-board.html]
  B --> P[Review meeting, or<br/>print to PDF for the client]

The script asks for JSON instead of image bytes. The JSON has a CDN link to each image and the page's real title, so the board is one small HTML file that loads its images from the CDN. Nothing large goes through your machine.

The script

// audit.mjs: capture every page in every variant and build one HTML audit board.
// Node 22+, no dependencies.
//   RS_API_KEY=... node audit.mjs https://www.example.com / /pricing /blog
import { writeFile } from 'node:fs/promises';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const [origin, ...paths] = process.argv.slice(2);
if (!process.env.RS_API_KEY || !origin || paths.length === 0) {
  console.error('Usage: RS_API_KEY=... node audit.mjs <origin> <path> [path...]');
  process.exit(2);
}

// The columns of the board. Each variant changes one thing a reviewer cares about.
const VARIANTS = [
  { label: 'Desktop, light', body: { viewport: { width: 1440, height: 900 }, browser: { dark_mode: false } } },
  { label: 'Desktop, dark', body: { viewport: { width: 1440, height: 900 }, browser: { dark_mode: true } } },
  // scale: 1 overrides the device's 2x or 3x: a full-page phone capture at 3x is huge.
  { label: 'iPad Air', body: { device: 'ipad_air', viewport: { scale: 1 } } },
  { label: 'iPhone 14', body: { device: 'iphone_14', viewport: { scale: 1 }, browser: { dark_mode: false } } },
  { label: 'iPhone 14, dark', body: { device: 'iphone_14', viewport: { scale: 1 }, browser: { dark_mode: true } } },
];

async function capture(url, variant, attempt = 1) {
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.RS_API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({
      url,
      ...variant.body,
      capture: { mode: 'full_page', scroll: { simulate: true } },
      output: { format: 'webp', quality: 85 },
      block: { cookie_banners: true, chat_widgets: true },
      wait: { until: 'networkidle' },
      cache: { ttl: 2592000 }, // keep the board's images for 30 days, the maximum
    }),
  });
  const json = await res.json().catch(() => ({}));
  if (res.status === 429 && attempt < 4) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? '5') * 1000));
    return capture(url, variant, attempt + 1);
  }
  if (!res.ok) return { error: json.error?.code ?? `HTTP ${res.status}` };
  return { src: json.image.url, width: json.image.width, height: json.image.height, title: json.metadata?.title };
}

// Page titles come from the audited site: escape them before they go into HTML.
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

const rows = [];
let failures = 0;
for (const path of paths) {
  const url = new URL(path, origin).href;
  const cells = [];
  for (const variant of VARIANTS) {
    const shot = await capture(url, variant); // one at a time: gentle on the rate limit
    if (shot.error) failures++;
    cells.push(shot);
  }
  rows.push({ url, title: cells.find((c) => c.title)?.title ?? '', cells });
  console.log(`${url}: ${cells.filter((c) => !c.error).length}/${VARIANTS.length} captured`);
}

const html = `<!doctype html>
<meta charset="utf-8"><title>Design audit: ${esc(origin)}</title>
<style>
  body { font: 14px system-ui, sans-serif; margin: 24px; background: #f4f4f5; color: #18181b; }
  table { border-collapse: separate; border-spacing: 12px; }
  th { text-align: left; vertical-align: bottom; }
  td { vertical-align: top; background: #fff; padding: 8px; border-radius: 6px; }
  img { width: 220px; height: auto; display: block; }
  .err { color: #b91c1c; width: 220px; }
  textarea { width: 220px; height: 60px; margin-top: 6px; }
</style>
<h1>Design audit: ${esc(origin)}</h1>
<p>Captured ${new Date().toISOString().slice(0, 10)}. Images expire after 30 days.</p>
<table>
<tr><th>Page</th>${VARIANTS.map((v) => `<th>${esc(v.label)}</th>`).join('')}</tr>
${rows.map((r) => `<tr><th><a href="${esc(r.url)}">${esc(r.title || r.url)}</a></th>${r.cells
  .map((c, i) => c.error
    ? `<td><div class="err">${esc(c.error)}</div></td>`
    : `<td><a href="${esc(c.src)}"><img src="${esc(c.src)}" width="${c.width}" height="${c.height}" loading="lazy" alt="${esc(`${r.title || r.url}, ${VARIANTS[i].label}`)}"></a><textarea placeholder="Finding"></textarea></td>`)
  .join('')}</tr>`).join('\n')}
</table>`;

await writeFile('audit-board.html', html);
console.log(`Wrote audit-board.html (${rows.length} pages x ${VARIANTS.length} variants)`);
if (failures) process.exitCode = 1;

The page title comes from the audited site, so the script escapes it, and every other string, before it goes into the board. A title with <script> in it would otherwise run when a reviewer opens the file. A capture that fails shows its error code in the cell, and the script exits with code 1 so you notice before the meeting.

Tested on 2026-10-18 with Node 22.18.0 against a local stub of our API that returns the documented JSON body. A run over three paths wrote a board with 15 cells, and a path containing "<x> came out escaped in the HTML. We did not run it against the live API for this page, so the page shows no board image.

Running the review

  1. Run the script with the pages in the order of the user journey: home, product, pricing, sign-up, then the rest.
  2. Open audit-board.html and read one column at a time. Inconsistencies between pages on one device are easier to see than differences between devices.
  3. Type findings into the box under each image. The boxes are not saved when you close the tab; print the board to PDF to keep the notes and the images together.
  4. For a finding that needs detail, capture that one page again at full device scale, or with capture.mode: "element" on the component.

Two cautions. First, prefers-color-scheme is the only thing dark_mode changes, so a site that switches themes with a class needs a page script to set that class. Second, a board of a staging site behind a login needs cookies in network.cookies; anyone with an image link can open the image, so keep such boards inside the team.

What it costs

Each cell is one render. A failed cell costs nothing.

Audit Renders Plan that covers it
20 pages x 5 variants, once 100 Free: 150 free credits every month
60 pages x 5 variants, once a month 300 Starter: 2,500 credits for $10
40 client sites x 15 pages x 5 variants, once a month 3,000 Growth: 12,000 credits for $40

An agency doing the third row pays about $0.0133 per cell. The pricing page has the current numbers.

After the audit

Once the fixes ship, keep them from coming back. Visual regression testing diffs a pull request's preview against production with the same fixed settings this board uses. Get a free API key and run audit.mjs on your own site's five most visited pages first.

Frequently asked questions

Why are the phone captures at scale 1 and not the device's real pixel ratio?
A full-page capture at the iPhone 14's scale of 3 is three times as wide and three times as tall as at scale 1. On a board of 220-pixel thumbnails the extra detail is invisible and the files are large. Capture a single page again at full scale when a finding needs a close look.
Why does the dark column look the same as the light one?
browser.dark_mode sets the prefers-color-scheme media feature and nothing else. A site with no dark styles, or one that switches themes with a class instead of the media query, renders the same in both. That is a finding in itself for many audits.
Can I share the board with a client?
Yes. The images on the board are public CDN links that need no API key, kept for 30 days by the cache.ttl in the script. Send the HTML file, or print it to PDF to keep the images after the links expire.
Can the API capture pages that need a login?
Yes, with cookies or HTTP authentication in the network options of each request. The authenticated pages guide shows both. Keep such boards private, because anyone with an image link can open it.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.