DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to screenshot every Storybook component automatically

How to screenshot every Storybook component automatically

Your Storybook is the source of truth for your component library. But the visual docs go stale the moment someone changes a component without updating the story screenshots. Chromatic solves this but costs $149/month before you need more than a handful of snapshots. And rolling your own Puppeteer setup to screenshot every story is surprisingly brittle — Storybook's build output changes, stories load at different speeds, and local browsers render differently than your CI environment.

Here's how to screenshot every Storybook story automatically using the PageBolt API — no local browser required, consistent rendering every time.

How Storybook's URL structure works

Every Storybook story is accessible at a predictable URL:

http://localhost:6006/iframe.html?id=STORY_ID&viewMode=story
Enter fullscreen mode Exit fullscreen mode

Where STORY_ID follows the pattern component-name--story-name (all lowercase, spaces replaced with hyphens). For example:

  • button--primary
  • forms-input--with-label
  • navigation-header--logged-in

Storybook also exposes a JSON index of all stories at /index.json (or /stories.json in older versions). We can fetch that, get every story ID, and screenshot them all programmatically.

Step 1: Get all story IDs

// scripts/screenshot-storybook.js
const fs = require('fs');
const path = require('path');

const STORYBOOK_URL = process.env.STORYBOOK_URL || 'http://localhost:6006';
const OUTPUT_DIR = path.join('screenshots', 'components');
const PAGEBOLT_API = 'https://pagebolt.dev/api/v1/screenshot';

async function getStoryIds() {
  // Try /index.json first (Storybook 7+), fall back to /stories.json (Storybook 6)
  for (const endpoint of ['/index.json', '/stories.json']) {
    try {
      const res = await fetch(`${STORYBOOK_URL}${endpoint}`);
      if (!res.ok) continue;
      const data = await res.json();

      // Storybook 7 format: data.entries
      if (data.entries) {
        return Object.values(data.entries)
          .filter(entry => entry.type === 'story')
          .map(entry => ({ id: entry.id, title: entry.title, name: entry.name }));
      }

      // Storybook 6 format: data.stories
      if (data.stories) {
        return Object.values(data.stories)
          .map(story => ({ id: story.id, title: story.kind, name: story.name }));
      }
    } catch (err) {
      // continue to next endpoint
    }
  }

  throw new Error(`Could not fetch story index from ${STORYBOOK_URL}`);
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Screenshot each story via PageBolt

The selector parameter is the key here. Instead of screenshotting the full Storybook iframe (which includes padding and the white background), we pass #storybook-root to crop to just the rendered component.

async function screenshotStory(storyId, outputPath) {
  const storyUrl = `${STORYBOOK_URL}/iframe.html?id=${storyId}&viewMode=story`;

  const res = await fetch(PAGEBOLT_API, {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url: storyUrl,
      width: 1200,
      height: 800,
      format: 'png',
      selector: '#storybook-root',     // crop to component only
      waitForSelector: '#storybook-root > *', // wait for component to render
      waitUntil: 'networkidle2',
      blockAds: true,
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`PageBolt error for story ${storyId}: ${err.error}`);
  }

  const buffer = Buffer.from(await res.arrayBuffer());
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
  fs.writeFileSync(outputPath, buffer);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Loop through all stories with concurrency control

Screenshotting hundreds of stories one at a time is slow. Use a simple concurrency limiter to run multiple requests in parallel without hammering the API:

async function runWithConcurrency(tasks, limit) {
  const results = [];
  const executing = new Set();

  for (const task of tasks) {
    const p = task().then(result => {
      executing.delete(p);
      return result;
    });
    executing.add(p);
    results.push(p);

    if (executing.size >= limit) {
      await Promise.race(executing);
    }
  }

  return Promise.all(results);
}

async function screenshotAllStories() {
  fs.mkdirSync(OUTPUT_DIR, { recursive: true });

  console.log(`Fetching stories from ${STORYBOOK_URL}...`);
  const stories = await getStoryIds();
  console.log(`Found ${stories.length} stories`);

  const tasks = stories.map(story => async () => {
    // Build a safe file path from the story ID
    // e.g. "button--primary" -> "screenshots/components/button/primary.png"
    const [component, ...rest] = story.id.split('--');
    const storyName = rest.join('--') || 'default';
    const outputPath = path.join(OUTPUT_DIR, component, `${storyName}.png`);

    try {
      await screenshotStory(story.id, outputPath);
      console.log(`✓ ${story.id}`);
    } catch (err) {
      console.error(`✗ ${story.id}: ${err.message}`);
    }
  });

  // Run 5 screenshots concurrently
  await runWithConcurrency(tasks, 5);
  console.log(`\nScreenshots saved to ${OUTPUT_DIR}/`);
}

screenshotAllStories().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Run it against local Storybook:

PAGEBOLT_API_KEY=YOUR_API_KEY STORYBOOK_URL=http://localhost:6006 node scripts/screenshot-storybook.js
Enter fullscreen mode Exit fullscreen mode

Or against a deployed Storybook (Chromatic, Vercel, Netlify):

PAGEBOLT_API_KEY=YOUR_API_KEY STORYBOOK_URL=https://your-storybook.vercel.app node scripts/screenshot-storybook.js
Enter fullscreen mode Exit fullscreen mode

Optional: regression detection between runs

If you've already got baselines saved, add a diff step to detect changes:

const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');

function diffImages(baselinePath, currentBuffer) {
  if (!fs.existsSync(baselinePath)) {
    // No baseline yet — first run
    return { isNew: true, diffPercent: 0 };
  }

  const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
  const current = PNG.sync.read(currentBuffer);

  // Handle size mismatches (component grew or shrank)
  if (baseline.width !== current.width || baseline.height !== current.height) {
    return { isNew: false, diffPercent: 100, reason: 'size changed' };
  }

  const diff = new PNG({ width: baseline.width, height: baseline.height });
  const numDiffPixels = pixelmatch(
    baseline.data, current.data, diff.data,
    baseline.width, baseline.height,
    { threshold: 0.1 }
  );

  return {
    isNew: false,
    diffPercent: (numDiffPixels / (baseline.width * baseline.height)) * 100,
  };
}
Enter fullscreen mode Exit fullscreen mode

Integrate this into screenshotStory to fail the script when components change unexpectedly:

async function screenshotStory(storyId, outputPath) {
  // ... (fetch screenshot as before)
  const buffer = Buffer.from(await res.arrayBuffer());

  const { isNew, diffPercent, reason } = diffImages(outputPath, buffer);

  if (isNew) {
    fs.writeFileSync(outputPath, buffer);
    console.log(`  NEW: ${storyId}`);
  } else if (diffPercent > 0.5) {
    // Save the current version next to the baseline as "-current.png"
    const currentPath = outputPath.replace('.png', '-current.png');
    fs.writeFileSync(currentPath, buffer);
    throw new Error(`CHANGED (${diffPercent.toFixed(1)}% diff${reason ? ': ' + reason : ''}): ${storyId}`);
  } else {
    // Matches baseline — overwrite to keep file fresh
    fs.writeFileSync(outputPath, buffer);
  }
}
Enter fullscreen mode Exit fullscreen mode

GitHub Actions: screenshot all stories on PR

# .github/workflows/storybook-screenshots.yml
name: Storybook Screenshots

on:
  pull_request:
    branches: [main]

jobs:
  screenshot-components:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build Storybook
        run: npm run build-storybook

      - name: Serve Storybook
        run: npx serve storybook-static -p 6006 &
        # Wait for it to be ready
      - run: npx wait-on http://localhost:6006

      - name: Screenshot all stories
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
          STORYBOOK_URL: http://localhost:6006
        run: node scripts/screenshot-storybook.js

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        with:
          name: storybook-screenshots
          path: screenshots/components/
Enter fullscreen mode Exit fullscreen mode

Alternatively, if you deploy your Storybook to Vercel or Chromatic preview URLs, point STORYBOOK_URL at the deployed URL and skip the local build/serve steps entirely.

What you end up with

After the first run, screenshots/components/ looks like:

screenshots/components/
  button/
    primary.png
    secondary.png
    disabled.png
    loading.png
  forms-input/
    default.png
    with-label.png
    error-state.png
  navigation-header/
    logged-in.png
    logged-out.png
Enter fullscreen mode Exit fullscreen mode

Commit these to your repo. On every PR, the CI job re-screenshots everything and diffs against the committed baselines. Visual changes to components surface immediately, in CI, before they hit main.

For design review, the screenshots folder is also a static visual inventory of your entire component library — shareable with designers without requiring them to spin up Storybook locally.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)