Screens of a real app

Screenshot every page with Playwright, state by state

How to screenshot every page with Playwright: list routes and states, sign in once, wait for the right element, and save one stable image per state.

7 min read

To screenshot every page with Playwright, write down the routes and states you want, open each one in a browser context with a fixed viewport, wait for an element that proves the state is ready, then call page.screenshot() once per state. A script of about 40 lines covers most apps, including pages behind a sign-in. The rest of this guide is about what makes the images useful: states rather than URLs, a stable picture, and names you can find again.

List states, not only pages

A crawler that follows links finds URLs. Most of what you want to see has no URL of its own: the modal that opens on “New invoice”, the empty search, the validation error, the second tab of a settings page. Start from a list of states, and give each one the steps that reach it.

Good sources for that list:

  • the router file of the app, for every route;
  • the navigation menus, for what users actually reach;
  • the components that render conditionally (isEmpty, error, isOpen), for the states behind each route;
  • your support tickets, for the screens people get stuck on.
Screen State Route Steps to reach it
Invoices list /invoices none
Invoices empty search /invoices?q=zzz none
Invoices new invoice modal /invoices click “New invoice”
Invoices form error /invoices open the modal, submit it empty

If you are unsure which states a screen has, the UI states checklist goes through the usual ones: empty, loading, error, partial, full and more.

Install Playwright and write the script

The capture script does not need to live in your repository. Keeping it in its own folder avoids adding a dependency your app does not use:

npm init -y && npm install playwright && npx playwright install chromium

The playwright package is the library. You do not need the test runner (@playwright/test) to take screenshots; it becomes useful when you compare them with approved baselines, which is visual regression testing.

Each entry of STATES says where to go, what to do there, and which element proves the state is on screen:

import { chromium } from "playwright";
import { mkdir } from "node:fs/promises";

const BASE = process.env.BASE_URL ?? "http://localhost:3000";
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const STATES = [
  { screen: "Invoices", state: "list", route: "/invoices",
    ready: (p) => p.getByRole("row").nth(1) },
  { screen: "Invoices", state: "new invoice modal", route: "/invoices",
    steps: (p) => p.getByRole("button", { name: "New invoice" }).click(),
    ready: (p) => p.getByRole("dialog") },
];

await mkdir("screens", { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext({
  viewport: { width: 1440, height: 900 },
  storageState: "auth.json", // saved by the sign-in step below
  locale: "en-US", timezoneId: "UTC", reducedMotion: "reduce",
});
const page = await context.newPage();
await page.clock.setFixedTime(new Date("2026-03-02T10:00:00Z"));

for (const s of STATES) {
  await page.goto(BASE + s.route);
  if (s.steps) await s.steps(page);
  await s.ready(page).first().waitFor();
  const path = `screens/${slug(s.screen)}--${slug(s.state)}.jpg`;
  await page.screenshot({ path, type: "jpeg", quality: 80, animations: "disabled" });
  console.log("saved", path);
}
await browser.close();

Waiting for an element matters more than it looks. Playwright’s documentation marks waitUntil: "networkidle" as discouraged and recommends checking readiness through the page itself. A table row or an open dialog tells you the state is there; a quiet network only tells you nothing is loading at this instant.

Sign in once and reuse the session

For pages behind a login, sign in once, in a separate step, with a demo account, and save the session:

await page.goto(`${BASE}/login`);
await page.getByLabel("Email").fill(process.env.APP_EMAIL);
await page.getByLabel("Password").fill(process.env.APP_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("**/dashboard");
await page.context().storageState({ path: "auth.json" });

The capture script then starts already signed in. Three precautions, the first straight from Playwright’s authentication guide:

  • the state file holds cookies that can impersonate the account, so keep it out of version control;
  • pass the credentials as environment variables, never in the script;
  • use a demo account on demo data. A screenshot keeps everything the screen showed, and the privacy checklist for demo data lists what tends to slip through.

For apps with several roles, save one state file per role (admin.json, member.json) and run the list once per role. The same screen often has a different state for each.

Make every capture stable

Two runs of the same script should give the same picture. What usually changes between runs, and the screenshot option or setting that fixes it:

  • Time. “3 minutes ago” and today’s date. page.clock.setFixedTime() fixes what Date.now() and new Date() return.
  • Animations. A spinner or a transition caught halfway. animations: "disabled" fast-forwards finite animations and resets infinite ones; reducedMotion: "reduce" helps apps that respect that preference.
  • Volatile widgets. A chat bubble, a carousel, a cookie banner. The style option applies a stylesheet during the capture, for example style: ".chat-widget { visibility: hidden }".
  • Content you cannot control. A third-party avatar, a live price. mask: [locator] covers the element with a solid box, pink by default.
  • Hover. A pointer left over a button keeps its hover style. Move the mouse away before the capture.
  • The text caret. Hidden by default.

Keep the viewport identical across runs. page.screenshot() captures the viewport; fullPage: true captures the whole scrollable page. That suits long documents, but it produces images of very different heights, which are harder to compare side by side.

Phone, tablet and desktop

Playwright ships device descriptors that set the viewport, the user agent and touch support together:

import { chromium, devices } from "playwright";
const context = await browser.newContext({ ...devices["iPhone 13"], storageState: "auth.json" });

Run the same list once per device and put the device in the file name. Phone descriptors use a device scale factor above 1, so images come out larger than the viewport; pass scale: "css" to page.screenshot() for one image pixel per CSS pixel. Some states only exist at one size, like a collapsed menu on a phone: list those explicitly.

Name, store and index the images

A folder of 60 images is only useful if you can find the one you need:

  • name files screen--state.jpg, with the device or role when you run several, so they sort by screen;
  • log the URL and the date next to each file, in a small JSON or Markdown index;
  • prefer JPEG of quality 80 for review images: a 1440 x 900 window stays small, often around 150 KB;
  • capture again after each release rather than editing old images by hand.

A folder works for you alone. For a team review, each image needs context: which screen, which state, and what leads to it.

Send the captures to a canvas instead of a folder

agentappbuilder uses this approach for its Live app canvas. When you ask Claude Code to show your app, it first tells you in one message which screens it will capture and that the screenshots go to app.agentappbuilder.com, then waits for your confirmation, unless your own message already named both the Playwright install and that destination. It installs Playwright in a temporary folder outside your repository and adapts the script of its guide: one entry per state with the steps to reach it, a 1440 x 900 window (390 x 844 for a mobile app), a JPEG of quality 80. Instead of writing files, the script posts each image to the capture endpoint from your machine, so the image never goes through the model.

On the canvas, each screen shows its states side by side with the date of capture, linked by the actions that lead from one to the next. A new capture of a state replaces the previous one. You import the states you want into the prototype, then paste a frame URL into your chat to ask for a change. Getting started walks through it.

Whichever way you store them, the method stays the same: list states with the steps that reach them, sign in once with a demo account, wait for an element, freeze what moves, and capture again after every release.

Frequently asked questions

How do I take a full page screenshot in Playwright?

Pass fullPage: true to page.screenshot(), for example page.screenshot({ path: "home.png", fullPage: true }). Without it, Playwright captures only the visible viewport.

Should I wait for networkidle before a screenshot?

Playwright's documentation marks networkidle as discouraged. Wait for an element that proves the state is ready, such as a table row or a dialog, with locator.waitFor() or a web assertion.

How do I screenshot pages behind a login with Playwright?

Sign in once with a demo account, save the session with storageState({ path }), and create the capture context with that storageState. Keep the file out of your repository: it holds session cookies.

Can Playwright crawl every link automatically?

It can collect links and visit them, but a crawl only finds URLs. Modals, tabs, empty lists and errors have no URL of their own, so an explicit list of states covers more.

Try it on your own app

Tell Claude Code one sentence. It creates the project and gives you a link to see your screens.

Tell Claude Code:

Connect this project to agentappbuilder.com and show me its screens.