Back to Blog
Guide February 26, 2026

How to screenshot every Storybook component automatically

Automatically screenshot every component story in your Storybook and save them for visual documentation, regression detection, or design review.

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

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}`);
}

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);
}

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);

Run it against local Storybook:

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

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

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,
  };
}

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);
  }
}

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/

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

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

Get Started Free

100 requests/month, no credit card

Screenshots, PDFs, video recording, and browser automation — no headless browser to manage.

Get Your Free API Key →