Docs / Testing and review
Storybook screenshots with Playwright
Screenery has no Storybook integration. You don't need one to publish component images: a short Playwright script screenshots every story, and the Action publishes the folder. Your design-system docs then embed images that update when main does.
When this recipe fits
If you want visual review of components, use a tool built for Storybook. Checked October 2026:
- Chromatic is made by Storybook's maintainers and goes much deeper. It renders stories in its cloud browsers and has a review and sign-off workflow. TurboSnap uses your git history and dependency graph to test only the stories a change affects.
- Argos's package, @argos-ci/storybook, captures every story with Playwright in your own CI, through Storybook's Vitest addon or test runner. Your team reviews changes in the Argos web app.
Use this recipe when what you need is the image itself. Each story becomes a plain PNG at a URL like …/button/primary@latest.png, which works in a README, a docs site, a help centre or an email. It needs no iframe and no public Storybook. You can run it beside Chromatic or Argos.
Build and serve Storybook
A static build writes storybook-static/. Its index.json lists every story and docs page. Serve the folder on a local port:
npx storybook build npx --yes http-server storybook-static --port 6006 --silent
Capture every story
The script reads index.json, keeps the entries whose type is "story", and opens each story on its own through iframe.html. It needs playwright as a dev dependency.
// capture-stories.mjs: one PNG per story, named after the story id.
import { mkdir } from 'node:fs/promises';
import { chromium } from 'playwright';
const base = process.env.STORYBOOK_URL ?? 'http://127.0.0.1:6006';
const out = 'storybook-screenshots';
const index = await (await fetch(`${base}/index.json`)).json();
const stories = Object.values(index.entries).filter((entry) => entry.type === 'story');
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1024, height: 768 } });
for (const story of stories) {
// "example-button--primary" → storybook-screenshots/example-button/primary.png
const [component, name] = story.id.split('--');
await page.goto(`${base}/iframe.html?id=${story.id}&viewMode=story`);
await page.locator('#storybook-root > *').first().waitFor();
await page.evaluate(() => document.fonts.ready);
await mkdir(`${out}/${component}`, { recursive: true });
await page.locator('#storybook-root').screenshot({
path: `${out}/${component}/${name}.png`,
animations: 'disabled',
});
}
await browser.close();
console.log(`Captured ${stories.length} stories into ${out}/`);
We ran this script against Storybook 10.6 and Playwright 1.64. Before you rely on it, know its limits:
- It waits for the story's first element. A story that renders nothing, or renders into a portal outside #storybook-root, times out after 30 seconds. Screenshot the page for those stories instead.
- It does not wait for a play function. If a story has one, wait for something the play function shows before you take the screenshot.
- It captures the full width of the root. To crop tightly to the component, screenshot #storybook-root > * instead.
- Docs pages are skipped. Entries with type: "docs" are autodocs or MDX pages, not components.
Story ids become names
Screenery names each screenshot after its path inside the folder you push, minus the extension. It never rewrites a name, and each part must be lowercase letters, digits, ., _ or -. Storybook story ids are already lowercase and dashed, so they fit:
title 'Example/Button', export Primary → id example-button--primary storybook-screenshots/example-button/primary.png → example-button/primary https://cdn.screenery.dev/acme/design-system/example-button/primary@latest.png
- One folder per component. Screenery groups the screenshots in a folder, so a component's stories sit together in the console.
- A story id is a route. Storybook builds it from the title and the story's export name. Move a component in the sidebar and its URLs change, and the old @latest URLs answer 404 after the next default-branch push. Set id in the default export to keep them when you change the title. See Storybook's permalink docs.
- Check before you push. --dry-run lists every name and contacts nobody. A title with characters outside the rule fails here, before anything is sent.
npx screenery push ./storybook-screenshots --project acme/design-system --dry-run \ --commit-timestamp 2026-10-01T00:00:00Z
The workflow
The permissions are the same as in the minimal workflow. The server runs in the background, and a short loop waits for it:
name: storybook-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@v4
# … install dependencies …
- run: npx playwright install --with-deps chromium
- run: npx storybook build
- run: npx --yes http-server storybook-static --port 6006 --silent &
- run: until curl -sf http://127.0.0.1:6006/index.json > /dev/null; do sleep 1; done
- run: node capture-stories.mjs
- uses: screenery/push@v1
with:
project: acme/design-system
path: storybook-screenshots
On main the push moves @latest. On a pull request it moves @pr-12 and the preview comment shows what changed. To fail the job on a visual change, add diff-baseline; see Visual testing. Every input is on The GitHub Action.
Already capturing pages with Playwright tests? Those publish the same way. See Playwright goldens and Argos folders.
Embed a component
A public @latest URL is ordinary Markdown. Paste it into your design-system docs once, and it shows the component as it is on main:

Private projects, signed links and dark versions are covered on Screenshot URLs.