Screenshot automation for docs and READMEs with Playwright
People mean three different things by screenshot automation. This guide says which one fits docs and READMEs, then walks through a Playwright test that captures named screenshots in CI and publishes them at URLs that update on every merge.
Three kinds of screenshot automation
Desktop capture apps
These run on your own computer. They take a screenshot on a hotkey or a timer, or replay a recorded sequence, then save or upload the image. They suit bug reports, one-off how-tos and annotated images where a person decides what goes in the frame. They need someone's desktop to run on, though, and they can't tell when your UI changed. Taking the screenshot gets faster, but someone still has to notice it went stale and take it again.
URL screenshot APIs
You send a URL to a hosted service such as screenshotapi.net or Urlbox, and it sends back an image. That works well for public pages: link previews, social cards, archives, or watching a site you don't own. Your own product is harder. To get behind the login, the service needs a session for your app. To show an open dialog or a particular row of data, you script the clicks in the service's API instead of in your own code.
Capture from tests in CI
Your end-to-end tests already sign in, load data and click their way to each screen. Add page.screenshot() and the test that checks a screen also produces its picture, on every build. Kong described a version of this in Docs as Code: Screenshot Automation, driving shot-scraper from a YAML file. fastlane snapshot does the same for App Store images from iOS UI tests.
The capture is the easy half. The image still has to get from the CI runner to the docs page. You can commit it back to the repository, keep it as a workflow artifact, or push it somewhere that gives it a stable URL. Screenery does the third.
| Kind | Fits | Falls short when |
|---|---|---|
| Desktop capture app | One-off and annotated images | The UI changes and nobody retakes the shot |
| URL screenshot API | Public pages, previews, monitoring | The screen is behind a login or needs clicks to reach |
| Tests in CI | Product screenshots in docs and READMEs | You have no end-to-end tests and no CI job |
For docs and READMEs, use tests in CI. The other two kinds produce a file. A test produces a new file on every build.
A worked example with Playwright
This example signs in once, captures three named screenshots, and publishes them with GitHub Actions to a public Screenery project called acme/dashboard. It assumes you created that project with the Screenery GitHub App, as in Getting started.
1. Configure Playwright
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests',
use: {
baseURL: 'http://localhost:3000',
viewport: { width: 1280, height: 800 },
locale: 'en-US',
timezoneId: 'UTC',
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'docs',
testMatch: /docs\.spec\.ts/,
dependencies: ['setup'],
use: { storageState: 'playwright/.auth/user.json' },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
A fixed viewport, locale and time zone keep the picture the same on every machine. The setup project signs in before the screenshot tests run.
2. Sign in once
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('sign in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.DOCS_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.DOCS_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
Use a test account that exists only in the copy of the app the job starts. Add playwright/.auth to .gitignore, because the file holds a live session. Playwright's authentication guide covers other setups, such as one account per worker.
3. Capture named screenshots
// tests/docs.spec.ts
import { test, expect, type Page } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(new Date('2026-01-15T10:00:00Z'));
await page.route('**/api/projects', (route) =>
route.fulfill({ path: 'tests/fixtures/projects.json' }),
);
});
async function capture(page: Page, name: string) {
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.screenshot({
path: `screenshots/${name}.png`,
animations: 'disabled',
caret: 'hide',
});
}
for (const [viewport, size] of [
['desktop', { width: 1280, height: 800 }],
['mobile', { width: 390, height: 844 }],
] as const) {
test(`projects list (${viewport})`, async ({ page }) => {
await page.setViewportSize(size);
await page.goto('/projects');
await expect(page.getByRole('row')).toHaveCount(4);
await capture(page, `projects/list/${viewport}`);
});
}
test('billing settings', async ({ page }) => {
await page.goto('/settings/billing');
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
await capture(page, 'settings/billing');
});
The page.route call answers the app's /api/projects request with a file you commit, so the list shows the same projects on every run. Create tests/fixtures/projects.json in the shape your API returns. This example has three projects. The table adds a header row, which is why the test expects four rows.
[
{ "id": "p_1", "name": "Marketing site", "status": "Active", "updatedAt": "2026-01-14T09:00:00Z" },
{ "id": "p_2", "name": "Mobile app", "status": "Active", "updatedAt": "2026-01-12T16:30:00Z" },
{ "id": "p_3", "name": "Docs", "status": "Archived", "updatedAt": "2025-12-01T08:15:00Z" }
]
Rename the fields to match your API, and change the row count in the assertion if you add or remove projects. Playwright sends the file as the response and sets its content type from the .json extension.
Each file's path under screenshots/, minus the extension, becomes its name. screenshots/projects/list/mobile.png is published as projects/list/mobile. Names use lowercase letters, digits, ., _ and -, and the viewport goes last. See Names.
Write to a folder of your own rather than Playwright's test-results/, which also holds traces and failure images. Screenery publishes every image in the folder you push.
4. Publish from CI
Save this as .github/workflows/screenshots.yml:
name: screenshots
on:
push:
branches: [main]
pull_request:
jobs:
screenshots:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
env:
DOCS_USER_EMAIL: ${{ secrets.DOCS_USER_EMAIL }}
DOCS_USER_PASSWORD: ${{ secrets.DOCS_USER_PASSWORD }}
- uses: screenery/push@v1
with:
project: acme/dashboard
path: screenshots
id-token: write lets the job prove to Screenery which repository it runs for, so there is no Screenery secret to store. pull-requests: write lets the job post the preview comment on pull requests. The push step has no if:, so a failing test publishes nothing. Every input is on the GitHub Action reference, and Playwright screenshots in GitHub Actions explains each line.
5. Embed the URLs
The push log prints a snippet for each public screenshot. Paste it once:

In HTML, or to set the display width:
<img src="https://cdn.screenery.dev/acme/dashboard/settings/billing@latest.png"
alt="Billing settings with the current plan and payment method" width="640" />
The same URL works in a GitHub README, in Markdown or MDX on any docs site, and in a help centre that accepts an image URL. For Mintlify's components, see Self-updating screenshots in Mintlify docs.
Keep them current with @latest
@latest is a channel. Screenery looks it up on every request, so an embed always shows the newest build without anyone editing the page.
- Merging moves it. A push on the default branch points @latest at the new build. There is no cache to purge, though a browser may keep a copy for up to five minutes.
- A pull request never moves it. Its screenshots land on a channel such as @pr-12, and the preview comment shows what changed against @latest.
- The first push creates it, from whatever branch it came from, so your URLs work before the setup pull request merges. That happens once per project.
- Renaming a test breaks its URL. There are no redirects. After the next default-branch push, the old name's @latest answers 404.
- A version URL never changes. Use @v_… on a page that must keep one exact image, such as a changelog entry.
The full rules are in How @latest moves.
Same screen, same picture
A docs screenshot should change when the UI changes and at no other time. Otherwise every pull request shows a dozen changed images and people stop looking. Most of the noise comes from five places.
The login
Sign in once in a setup project and reuse the saved state, as in step 2. Signing in inside every test is slower, and a login page that flickers into one screenshot is easy to miss.
Dates and data
"Updated 3 minutes ago" and a growing list of projects change the picture on every run. page.clock.setFixedTime() pins the date the page sees, and page.route() answers API calls from a fixture file. Seeding the test database works too. Playwright's mask option also hides changing parts, but it paints them a solid colour, which looks wrong in a docs page. Fixed data shows the screen the way a reader will see it.
Fonts
A Linux runner doesn't have the fonts on your laptop. If your CSS falls back to system fonts, CI renders different letter shapes and widths. Load your own web fonts, and wait for document.fonts.ready before the capture, as the helper in step 3 does. Publish only the screenshots CI takes, so every image comes from the same machine type.
Viewport and scale
Set the viewport in the config, and per test when you need a second size. Put the viewport last in the name, as in projects/list/mobile. For sharper images on high-density screens, set deviceScaleFactor: 2. That doubles the pixel size of every file, so set the display width in the docs page.
Motion
page.screenshot() lets animations run unless you pass animations: 'disabled'. A half-finished transition or a blinking caret makes two runs differ. Wait for real content with an assertion, not a fixed timeout.
Once these hold, the preview comment's count of changed screenshots means something. To fail the build when a screenshot changes by accident, give the Action a committed baseline. See Visual regression testing in CI.
FAQ
- What is screenshot automation?
- Taking screenshots with a program instead of by hand. The program might be a desktop app on a timer, a hosted API that renders a URL, or a test that drives your app in CI. For docs and READMEs, the test is the one that knows when the UI changed.
- Can I automate screenshots without writing tests?
- Yes. A URL screenshot API needs only a URL, and shot-scraper takes a YAML list of pages. Screenery publishes any folder of PNG, JPEG, WebP, AVIF, GIF, WebM or MP4 files, so the output of either can be pushed the same way. Tests win once a screenshot needs a login, seeded data or a click to reach the right state.
- How do I take screenshots of pages behind a login?
- Sign in once in a Playwright setup project with a test account, save the session with storageState, and let every screenshot test reuse it. The account only has to exist in the copy of the app your CI job starts, and the password stays in CI.
- Why does my screenshot look different in CI than on my laptop?
- The CI runner has a different operating system, different system fonts and often a different screen scale. Load your own web fonts, set the viewport and device scale factor in the Playwright config, and publish only the screenshots CI takes.
- Will a pull request change the screenshots in my docs?
- No. A pull request publishes to its own channel, such as @pr-12, and the preview comment shows what changed. Only a push on the default branch, or a promotion, moves @latest. The one exception is a new project's first push, which creates @latest from whatever branch it came from.
Related guides
- How to keep documentation screenshots up to date
- README screenshots that update themselves
- Self-updating screenshots in Mintlify docs
- Playwright screenshots in GitHub Actions
- Markdown image syntax
Third-party tools are described from their own pages, checked October 2026. Follow the links for their current features.