Storybook
Run Frontguard against a Storybook to catch component-level visual regressions — without paying for Chromatic.
Storybook integration
Frontguard ships a first-class Storybook discovery adapter. Point it at a
running Storybook (locally or in CI) and Frontguard will enumerate every
story, navigate the preview iframe directly, wait for each story's play()
function to finish, and capture multi-viewport screenshots — diffed against
git-tracked baselines and (optionally) analysed by AI.
It is not a thin wrapper around the Playwright plugin: discovery comes
from Storybook's own index.json, per-story metadata flows in through
parameters.frontguard, and the renderer is play()-aware so the
post-interaction state of a story is what ends up in your baseline.
Looking for the broader story (CLI flags, AI analysis, sandbox fix verification)? See Quick Start and the CLI reference — everything that works for full pages works the same way against Storybook stories.
Why use Frontguard with Storybook?
You probably already have a visual testing story (if you have one at all) that looks like one of these:
| Option | Strengths | What's missing |
|---|---|---|
| Chromatic | Built by the Storybook team, mature review UI, deep ecosystem integration. | $179/mo+ above the 5k snapshot free tier, no production monitoring, no fix suggestions, locked to Storybook-only — full-page regressions never get tested. |
| Storybook test-runner + Playwright | Free, runs in CI. | You build your own diffing, no AI analysis, no baseline storage, no PR comments. |
| Lost Pixel / BackstopJS | Free, decent diff UX. | Pixel comparison only, no AI, no auto-fix, project is going stale (see BackstopJS migration guide). |
| Roll-your-own page screenshots | Total control. | You'll spend a quarter doing this and another quarter chasing flakes. |
Frontguard's Storybook mode keeps the parts you actually liked about Chromatic — every story captured automatically, per-story configuration via parameters, CI-friendly — and adds the pieces it was missing:
- AI classification — every diff is tagged
regression,intentional, orcontent_updateso you stop blind-approving everything. - Auto-fix suggestions — when the AI thinks it knows the CSS that broke,
it ships you a patch. With
verifyFixes: trueit runs the patch in a local (or Daytona) sandbox and confirms the diff actually goes away. - Self-hosted by default — baselines live in a git orphan branch. Frontguard never charges per snapshot.
- Same tool for stories AND full pages — flip
storybookon for the component library, off for the marketing site. Same baselines layout, same PR comments, same dashboard.
Quick start
1. Install
npm install -D @frontguard/cli
npx playwright install chromiumpnpm add -D @frontguard/cli
pnpm exec playwright install chromiumyarn add -D @frontguard/cli
yarn playwright install chromium2. Scaffold a Storybook-aware config
npx -p @frontguard/cli frontguard initWhen frontguard init sees a .storybook/main.ts (or .js / .mjs /
.cjs) in your project, it emits a Storybook-aware config and pre-points
baseUrl at the Storybook dev server:
import type { FrontguardConfig } from '@frontguard/cli';
export default {
version: 1,
baseUrl: 'http://localhost:6006',
viewports: [375, 768, 1440],
browsers: ['chromium'],
threshold: 0.1,
ignore: [],
smartRender: true,
workers: 4,
pageTimeout: 30_000,
maxHeight: 5_000,
outputDir: './frontguard-report',
storybook: {
url: 'http://localhost:6006',
stories: ['**'], // enumerate every story; narrow with globs if needed
},
} satisfies FrontguardConfig;Force the storybook block on a non-Storybook project (rare) with
frontguard init --storybook. Suppress detection with
frontguard init --no-storybook.
3. Start Storybook + run Frontguard
# In one terminal — Storybook 8 listens on 6006 by default
npm run storybook
# In another terminal
npx -p @frontguard/cli frontguard runThe first run captures baselines; subsequent runs diff against them.
You should see something like:
✔ Storybook discovery (v8): found 23 stories
✔ Rendering — Chromium 1440px [23/23]
✔ All 23 stories match baselinesHow discovery works
When storybook.url is set, Frontguard:
- Fetches the story index. It hits
<url>/index.json(Storybook 8 and recent 7.6+) first. If that 404s, it falls back to<url>/stories.json(Storybook 7.x). Frontguard reports the detected major version in the discovery log. - Filters out non-story entries. Anything with
type === 'docs'is skipped — autodocs entries aren't capturable as a single preview frame. - Applies your include / exclude filters (
storybook.storiesandstorybook.exclude). - Reads per-story
parameters.frontguardto override viewports, threshold, ignore rules, or to skip stories entirely. - Emits one route per story with the path
/iframe.html?id=<story-id>&viewMode=storyanddiscoveredVia: 'storybook'. The renderer uses that marker to wait forplay()before capturing.
You don't need to write any Frontguard glue per story. Discovery is purely declarative — the source of truth is your stories themselves.
Filtering stories
storybook.stories and storybook.exclude both accept the same patterns:
- A story id:
'components-button--primary' - A glob over title or
title/name:'Forms/*','Components/Button/*' - A title prefix:
'Components/Button'
export default {
// ...
storybook: {
url: 'http://localhost:6006',
// Only test buttons + form components
stories: ['Components/Button/*', 'Forms/*'],
// Skip work-in-progress stories
exclude: ['*--wip', 'Internal/*'],
},
} satisfies FrontguardConfig;Per-story configuration
Story files own per-story configuration via parameters.frontguard. The
shape is:
interface StoryFrontguardParameters {
viewports?: number[]; // narrow / widen the viewport set
threshold?: number; // fraction 0–1, e.g. 0.005
ignore?: IgnoreRule[]; // mask selectors / rects
skip?: boolean; // skip this story entirely
label?: string; // override the report label
}Real-world examples:
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta = { title: 'Components/Button', component: Button } satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: { variant: 'primary' },
// No overrides — uses global config (375 / 768 / 1440, threshold 0.1).
};
export const Secondary: Story = {
args: { variant: 'secondary' },
parameters: {
frontguard: {
// This button looks identical at every width — only test once.
viewports: [768],
// Tighten the threshold because we know this surface is static.
threshold: 0.005,
},
},
};
export const Danger: Story = {
args: { variant: 'danger' },
parameters: {
frontguard: {
// The hover ring's subtle gradient over-flags pixelmatch.
ignore: [{ selector: '.fg-hover-ring' }],
},
},
};
export const InProgress: Story = {
parameters: {
// Don't test until I finish redesigning this.
frontguard: { skip: true },
},
};Global defaults live in .storybook/preview.ts:
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
frontguard: {
// Storybook-wide default viewports.
viewports: [375, 768, 1440],
},
},
};
export default preview;Frontguard reads parameters.frontguard from the story level when it
exists. The preview-level block is informational today — set Storybook-wide
defaults via frontguard.config.ts (viewports, threshold, ignore)
for now, and override per story with the parameters.frontguard block.
play() awareness
Stories that ship a play() function are first-class. Frontguard's
renderer detects a Storybook route, then injects a wait script that:
- Polls
window.__STORYBOOK_PREVIEW__.storyRendersfor a phase ofcompleted/played/errored(Storybook 8). - Subscribes to the preview
'storyRendered'channel event (Storybook 7). - Falls back to a DOM-marker heuristic (
body.sb-show-mainAND notsb-show-preparingStory) when neither API is present. - Times out after the global
pageTimeoutand captures anyway, with a warning in the log so you can debug a stuck play function.
That means stories like this one capture after the interaction:
import { userEvent, within, expect } from '@storybook/test';
import { Modal } from './Modal';
export const OpenedByPlay = {
args: { defaultOpen: false, triggerLabel: 'Open the modal' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByTestId('modal-trigger');
await userEvent.click(trigger);
await expect(await canvas.findByTestId('modal-overlay')).toBeInTheDocument();
},
};What you get in your baseline is the opened modal, not the closed trigger.
If your previous Chromatic setup leaned on play() to set the state of an
interactive component, your stories port over unchanged.
Storybook 7 vs 8
Frontguard supports both. The differences you'll feel:
| Feature | Storybook 7.x | Storybook 8.x |
|---|---|---|
| Index endpoint | /stories.json | /index.json (Frontguard falls back to /stories.json if absent) |
| Reported version | v7 in discovery log | v8 in discovery log |
play() wait signal | 'storyRendered' channel event | storyRenders map + phase field |
parameters.frontguard | Supported | Supported |
| Recommended | If you can upgrade, do — Storybook 8 has a faster preview and a real test API. | Default for new projects. |
If both endpoints return non-200, Frontguard logs:
Could not fetch Storybook index at http://localhost:6006. Is Storybook running?
Frontguard tried /index.json (Storybook 8) and /stories.json (Storybook 7).The most common cause is Storybook still starting — wait for the manager built log line, or pass --ci to storybook dev to suppress the auto-open
behaviour in CI.
CI recipe
The standard pattern: boot Storybook in headless / --ci mode, wait for it
to listen, then run Frontguard against it.
name: Frontguard
on:
pull_request:
push:
branches: [main]
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required so the baseline orphan branch is reachable
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Start Storybook
run: npm run storybook -- --ci --quiet &
- name: Wait for Storybook
run: npx wait-on http://localhost:6006
- name: Frontguard
run: npx -p @frontguard/cli frontguard run
env:
# Optional: AI classification + auto-fix suggestions
FRONTGUARD_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: frontguard-report
path: frontguard-report/frontguard init --ci will scaffold this workflow for you (with the
storybook command and port pre-filled when it detects a Storybook project).
Faster CI: prebuild Storybook
For larger projects, prebuilding to static HTML once per branch is faster
than running storybook dev on every PR:
- name: Build Storybook
run: npm run build-storybook
- name: Serve static Storybook
run: npx http-server storybook-static -p 6006 &
- run: npx wait-on http://localhost:6006
- run: npx -p @frontguard/cli frontguard run/index.json is included in the static build, so discovery still works.
vs Chromatic Visual Tests addon
The Chromatic Storybook addon is the closest thing in the ecosystem. Here's where Frontguard differs:
- No per-snapshot fees. Chromatic charges by the snapshot beyond its free tier (5,000 snapshots/mo). Frontguard's cost is wherever your CI runs and (optionally) what you pay your AI provider — for most teams that's well under $0.01 per run.
- AI classification, not just diff approval. Chromatic shows you the diff and asks you to accept or reject. Frontguard does that and tells you whether the change looks like a regression, an intentional update, or content drift — with a confidence score. For teams that grew tired of blind-approving 30 diffs every PR, that's the productivity win.
- Auto-fix suggestions. When Frontguard's analysis identifies a CSS
cause (overflow, spacing, z-index, responsive break, color shift), it
ships you a patch. With
verifyFixes: trueit tests the patch in a sandbox before showing it to you. - Self-hosted baselines. Frontguard's baselines live in a git orphan branch, not a vendor's database. There is no lock-in: if Frontguard goes away tomorrow, your baselines stay in your repo.
- Same tool for stories + full pages. Chromatic's pricing and product surface assume Storybook-only. Frontguard is built so the same config can capture stories, then point at a deployed preview URL to capture the marketing site. One tool, one report, one PR comment.
- What Chromatic still does better: UI review workflow polish (their
diff-approval UI has years of iteration on it), TurboSnap (their
affected-story detection is excellent), and ecosystem credibility from
the Storybook team. If those matter more than cost + AI, Chromatic is a
fair choice — Frontguard ships a
frontguard-vs-chromaticmigration guide for the other case.
For competitive context see
docs/research/competitive-analysis.md § 2 Chromatic.
Troubleshooting
"Storybook discovery returned 0 stories"
The most common cause is Storybook still starting. Confirm with:
curl -fsS http://localhost:6006/index.json | headIf that returns a JSON blob with an entries key, Frontguard should
discover them. If it 404s, your Storybook version is too old to publish an
index. Update to Storybook 7.6+ (preferably 8.x).
Stories with play() are captured pre-interaction
Frontguard waits up to pageTimeout (30s by default) for the play function
to settle. If a story is captured pre-play, the wait probably timed out.
Check the run log for Storybook ready-wait failed warnings and lengthen
pageTimeout if your play() blocks on real network calls.
Per-story viewports aren't being honoured
Confirm the story's parameters are shaped exactly like:
parameters: {
frontguard: {
viewports: [768],
},
},The viewports key must be inside frontguard, not at the top of
parameters. The Storybook viewports addon's parameters.viewport block
is unrelated and not read by Frontguard.
"/index.json returns 404, /stories.json returns the index"
You're on Storybook 7.x without the v8 publisher. Frontguard already
handles this — it falls back to /stories.json automatically. The log
will show Storybook discovery (v7): .... If you want to silence the
404 noise in dev tools, upgrade Storybook or use the
storybook-addon-v5-index
publisher.
CI: Storybook starts but tests hit before it's ready
Add npx wait-on http://localhost:6006/index.json before the frontguard
step. Waiting on the URL alone isn't enough — Storybook can be serving the
manager UI before index.json is generated.
Worked example: the fixture
The Frontguard repo ships a runnable Storybook fixture at
packages/cli/__fixtures__/storybook/.
It exercises every code path documented above:
- Two component stories files (
Button.stories.tsx,Modal.stories.tsx). - A
play()-driven story (Modal/OpenedByPlay). - Per-story
parameters.frontguardoverrides for viewports, threshold, and ignore rules. - A
.storybook/main.ts+preview.tspair for Storybook 8 (Vite builder). - A
frontguard.config.tsyou can run directly.
To boot the fixture:
cd packages/cli/__fixtures__/storybook
npm install
npm run storybook # http://localhost:6006
# in another terminal
npx -p @frontguard/cli frontguard run --config ./frontguard.config.tsUse it as a working reference whenever your own project's setup misbehaves.
Next steps
- Quick Start — the 90-second tour of
frontguard run, PR comments, and baselines. - CLI reference — every flag the CLI accepts, including
--verify-fixes. AI analysis and baseline strategy are enabled infrontguard.config.ts(plus the relevant API-key env vars), not via CLI flags. - CI/CD recipes — patterns for GitHub Actions, GitLab CI, and Vercel preview integration.
MCP server
Use Frontguard from inside your IDE. The @frontguard/mcp server exposes list_regressions, get_suggested_fix, accept_baseline, and recent_runs over the Model Context Protocol so Claude Code, Cursor, and Copilot can answer "what regressed on this PR?" without leaving the editor.
Netlify
Run Frontguard visual regression on every Netlify deploy preview using the official Build Plugin.