Screenery

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:

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:

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
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:

![Primary button](https://cdn.screenery.dev/acme/design-system/example-button/primary@latest.png)

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