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--primaryforms-input--with-labelnavigation-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
Top comments (0)