Self-contained visual regression testing for Storybook stories running on Vitest browser mode
storybook-addon-vrt
Self-contained visual regression testing for Storybook stories running on
Vitest browser mode via
@storybook/addon-vitest.
- ๐ธ Per-story screenshots โ captured automatically after each story test (including play functions), no extra test code.
- ๐ One command โ
svrt runcaptures with Vitest and compares against baselines in a single step; no env var, no ordering to get wrong. - ๐ Honest compare engine โ classifies every screenshot as
passed,changed(with a pixel diff image),added,removed,skipped, orcarried, each with a machine-readable reason. "Not verified" is never reported as a failure. - โก Incremental runs โ opt into
--changedto capture only the stories a PR affects, with a git guard that refuses to pass green on a broken diff. - ๐ Reports โ console summary,
report.json, and a self-containedreport.htmlwith side-by-side / slider / blink viewers. - ๐ฌ PR comments โ
svrt comment(or the bundled GitHub Action) posts the verdict to the pull request, with inline diff images when the report is hosted somewhere public. - ๐ชถ Minimal coupling โ no dependency on Storybook packages and no third-party VRT services, so major upgrades of Storybook or Vitest are unlikely to break it.
Requirements
- Storybook 10+ with
@storybook/addon-vitestconfigured - Vitest 4 browser mode with the Playwright provider
- Node.js 22+
Setup
npm install --save-dev storybook-addon-vrt
Add the vrt() plugin to the same Vitest project as storybookTest():
// vitest.config.ts / vite.config.ts
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { vrt } from 'storybook-addon-vrt/vitest-plugin';
export default defineConfig({
test: {
projects: [
{
plugins: [storybookTest({ configDir: '.storybook' }), vrt()],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium', context: { reducedMotion: 'reduce' } }],
},
},
},
],
},
});
Commit the baselines, ignore everything else โ add to .gitignore:
.vrt/
!.vrt/expected/
Capture is off unless svrt run (or VRT=1) turns it on, so a plain
vitest run keeps zero overhead.
Quickstart
npx svrt run # capture every story with Vitest, then compare
npx svrt report # open the HTML diff report
npx svrt approve # promote the changes to baselines โ git add .vrt/expected
The first run has no baselines yet, so every story is added โ run
svrt approve once to seed them.
Commands
svrt run is the front door and does the whole loop. The rest are primitives
for split CI jobs, review, and debugging. Every command accepts the global
flags --config <file>, --base-dir, --expected-dir, --actual-dir,
--diff-dir.
svrt run [--changed [ref]] [--strict] [--fail-on <list>] [--open] [-- <vitest args>]
Resolves the run plan, wipes the actual slate, spawns Vitest with capture
enabled (it sets the env var for you), then compares and writes the report.
Anything after -- is forwarded verbatim to Vitest.
| Flag | Effect |
|---|---|
--changed [ref] |
Incremental: capture only stories affected since ref (git). Bare --changed uses the uncommitted working tree. See Incremental runs. |
--strict |
With --changed, exit 3 instead of falling back to a full run when git cannot produce a trustworthy diff. |
--fail-on <list> |
Which categories fail the run (default changed,added,removed). |
--threshold <n> ยท --allowed-mismatched-pixels <n> ยท --allowed-mismatched-pixel-ratio <n> |
Tune the pixel comparison. |
--open |
Open the HTML report when there are findings. |
svrt compare [--partial] [--fail-on <list>] [--threshold <n>] [--open]
Compares .vrt/actual against .vrt/expected without running Vitest โ
for split CI (capture in a browser container, compare in a lean one) or
re-tuning a threshold. Reads the run mode from .vrt/run.json.
--partialโ treat as a--changedrun: baselines with no capture arecarried, never a failingremoved.
svrt approve [--filter <glob>] [--prune] [--dry-run]
Copies each actual over its baseline. A story that ran but wasn't captured
(a vrt.skip, a failed test) is never treated as an orphan.
--filter <glob>โ only act on screenshot keys matching the glob.--pruneโ also delete baselines with no capture. Only safe after a full run.--dry-runโ print the operations without touching files.
svrt report
Opens the self-contained .vrt/report.html โ a review UI with side-by-side,
slider, and blink diff viewers, filterable by status.
svrt comment [--pr <n>] [--repo <owner/name>] [--report-url <url>] [--id <name>] [--max-entries <n>] [--dry-run]
Posts the result of the last run as a GitHub pull request comment โ one
marker-tagged comment per PR, updated in place on every push. Reads
.vrt/report.json, so run svrt run or svrt compare first.
- Auth: set the
GITHUB_TOKENenv var (needspull-requests: write). Onpull_requestevents the PR number and repository are auto-detected from the Actions environment; elsewhere pass--pr(and--repo). --report-url <url>โ public URL where this run's.vrtdirectory is hosted. With it the comment embeds expected/actual/diff images and links<url>/report.html; without it the comment degrades to a text summary pointing at the CI artifact.- A clean run never creates a comment (no bot noise on green PRs) โ it only refreshes an existing comment back to โ .
--id <name>โ namespaces the comment (marker and heading). Required when multiple VRT projects comment on the same PR โ e.g. a monorepo with several Storybooks โ so each keeps its own sticky comment instead of overwriting the others:svrt comment --id admin.--dry-runprints the markdown without posting.
svrt plan [--changed [ref]] [--json]
Shows exactly what svrt run would do โ full or incremental, the resolved
base SHA, which files changed, which fullRunTriggers matched, and the exact
Vitest args โ without launching a browser. --json for scripts.
Statuses
Every screenshot key lands in exactly one status. Three fail a run; three mean "not verified this time" and never do.
| Status | Fails? | Meaning |
|---|---|---|
passed |
no | Actual matches the baseline within tolerance. |
changed |
yes | Pixels or dimensions differ; a diff image is written. |
added |
yes | A capture with no baseline โ a new story. |
removed |
yes | A baseline with no capture in a full run โ the story is gone. |
skipped |
no | The story ran but wasn't captured on purpose (vrt.skip, a skipped/failed test). Baseline kept. |
carried |
no | Not selected by --changed; carried forward unverified. A full run re-checks it. |
removed vs carried is what the run mode decides: in a full run a missing
capture means the story is gone (removed); in a --changed run it just
means "not selected this time" (carried). Non-passed items carry a
machine-readable reason in report.json.
Exit codes
| Code | Meaning | Commands |
|---|---|---|
0 |
Success โ nothing in failOn. |
all |
1 |
Visual findings (changed / added / removed). | run, compare |
2 |
Usage or config error, git guard message, no report. | all |
3 |
Could not verify โ --strict guard refusal, or Vitest exited non-zero. |
run |
How it works
- The plugin injects a setup file into the Vitest project. Its
afterEachhook runs after every story test โ i.e. after rendering and the play function. - The hook waits for
document.fonts.ready, disables CSS animations/transitions/caret, applies story-level masking, and retakes screenshots until two consecutive ones hash identically (anti-flake). - The PNG is saved as
.vrt/actual/<stories-file-path>/<story-name>.png, e.g.src/components/button/button.stories.tsx/Primary.png. A story that ran but wasn't captured leaves a marker in.vrt/uncaptured/with the reason. - Compare walks
.vrt/expectedand.vrt/actual, pixel-compares pairs with pixelmatch, and writes diff images,report.jsonandreport.htmlinto.vrt/.
A failing story test is never captured, and a screenshot that does not stabilize logs a warning instead of failing your test run.
The injected CSS cannot stop JS-driven animation loops (Framer Motion / the
motion package, GSAP, rAF-based tweens): those keep mutating inline styles,
and under CI CPU contention a stalled frame can look "stable" to the retake
loop yet sit mid-animation. If your components animate with such libraries,
also emulate reduced motion in the browser context and have the app honor it
(e.g. Motion's <MotionConfig reducedMotion="user">):
// vitest.config.ts
provider: playwright({
contextOptions: { reducedMotion: 'reduce' },
}),
Story parameters
Per-story overrides via parameters.vrt:
export const Dashboard: Story = {
parameters: {
vrt: {
mask: ['[data-timestamp]', '.avatar'], // cover dynamic elements
remove: '.ads', // drop elements from layout
delay: 250, // extra ms before capturing
capture: '#main-panel', // capture an element, not the viewport
},
},
};
export const Flaky: Story = {
parameters: { vrt: { skip: true } }, // test still runs, no screenshot
};
| Parameter | Type | Description |
|---|---|---|
skip |
boolean |
Skip capturing this story (shows as skipped, baseline kept). |
delay |
number |
Extra milliseconds to wait before the stability checks. |
mask |
string | string[] |
CSS selector(s) covered by an opaque overlay. |
remove |
string | string[] |
CSS selector(s) removed from layout (display: none). |
capture |
'viewport' | string |
Capture the viewport (default) or the first matching element. |
The viewport size follows Storybook's own parameters.viewport
(addon-vitest applies it before the test runs).
Options
Options can be set inline (vrt({ ... })), in a vrt.config.json next to
the Vitest project root, or as CLI flags. Precedence:
CLI flags > inline plugin options > vrt.config.json > defaults.
| Option | Default | Description |
|---|---|---|
enabled |
!!process.env.VRT |
Inject the capture hook (svrt run sets this for you). |
baseDir |
.vrt |
Root for all VRT artifacts. |
expectedDir / actualDir / diffDir |
${baseDir}/{expected,actual,diff} |
Individual directory overrides. |
threshold |
0.1 |
pixelmatch per-pixel color threshold (0โ1). |
allowedMismatchedPixels |
โ | Tolerated mismatched pixel count. |
allowedMismatchedPixelRatio |
โ | Tolerated mismatched pixel ratio (0โ1). Stricter limit wins. |
failOn |
['changed','added','removed'] |
Categories that make a run exit 1. skipped/carried are never accepted. |
fullRunTriggers |
['**/.storybook/**'] |
Globs whose change forces a full run under --changed. See below. |
project |
'storybook' |
Vitest --project name svrt run passes (false = no filter). |
browserNameSuffix |
false |
Append .chromium etc. to keys (required for multiple instances). |
stability.retries |
5 |
Max screenshots taken while waiting for a stable image. |
stability.interval |
100 |
Milliseconds between stability screenshots. |
stability.disableAnimations |
true |
Inject animation/transition/caret-killing CSS. |
Optional preview annotation
The capture hook reads parameters.vrt from the test context provided by
addon-vitest. If an addon-vitest update ever changes those internals, add
the bundled decorator as a version-proof fallback:
// .storybook/preview.ts
import vrtPreview from 'storybook-addon-vrt/preview';
export default {
decorators: [...vrtPreview.decorators],
};
Baseline management
Screenshots only match when they were rendered in the same environment โ
macOS and Linux render fonts differently, so never compare images
captured on different OSes. Commit .vrt/expected/ and capture in one pinned
environment. The simplest way is the Playwright Docker image:
docker run --rm -v "$PWD":/work -w /work \
mcr.microsoft.com/playwright:v1.60.0-noble \
sh -c "corepack enable pnpm && pnpm install && pnpm exec svrt run"
svrt approve / svrt compare run anywhere โ they only read PNGs.
CI
Default to a full run on every PR โ correctness first. Pin the capture
environment so baselines don't drift with fonts. The bundled GitHub Action
wraps the whole loop โ svrt run, a PR comment with the verdict, and a
report artifact on findings:
# .github/workflows/vrt.yml
name: vrt
on: pull_request
jobs:
vrt:
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.60.0-noble
permissions:
pull-requests: write # let the Action comment on the PR
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- uses: k35o/storybook-addon-vrt@v0
with:
report-url: https://vrt.example.com/${{ github.event.number }} # optional
The Action's major tag tracks the npm package's major version (v0 today,
v1 from 1.0) and always points at the latest release of that major.
Action inputs (all optional): report-url and comment-id (see
svrt comment),
github-token, working-directory (monorepo package), run-args (e.g.
--changed origin/main), artifact-name. The comment step is best-effort โ
a failure to post never overrides the VRT verdict.
The Action is a thin wrapper over the CLI; the explicit equivalent is:
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm exec svrt run
- if: always()
env:
GITHUB_TOKEN: ${{ github.token }}
run: pnpm exec svrt comment
- if: failure()
uses: actions/upload-artifact@v4
with: { name: vrt-report, path: .vrt } # open report.html locally
report.html references images relatively, so the uploaded .vrt artifact
opens as a working report after download.
Package-manager variants of the run step: npx svrt run (npm),
pnpm exec svrt run (pnpm), yarn svrt run (yarn), bunx svrt run (bun).
Fork pull requests
On a PR from a fork, GitHub demotes GITHUB_TOKEN to read-only โ the PR
author's code runs in your CI, so a write token would be a takeover vector.
Capture and compare still work; only the comment 403s (the Action tolerates
that). To get comments on fork PRs, split the workflow in two: the
pull_request workflow only saves the report, and a workflow_run workflow โ
which runs on the base repository's definition and therefore may hold a write
token โ posts the comment from the saved artifact.
# .github/workflows/vrt.yml โ runs for forks too, needs no write access
name: vrt
on: pull_request
jobs:
vrt:
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.60.0-noble
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm exec svrt run
- if: always() # the comment workflow needs the PR number
run: echo "${{ github.event.number }}" > .vrt/PR_NUMBER
- if: always()
uses: actions/upload-artifact@v4
with: { name: vrt-report, path: .vrt }
# .github/workflows/vrt-comment.yml โ posts with the base repo's write token
name: vrt-comment
on:
workflow_run:
workflows: [vrt]
types: [completed]
jobs:
comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/download-artifact@v4
with:
name: vrt-report
path: .vrt
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- env:
GITHUB_TOKEN: ${{ github.token }}
run: npx --yes --package storybook-addon-vrt svrt comment --pr "$(cat .vrt/PR_NUMBER)"
Never check out or execute the PR's code in the workflow_run workflow โ it
holds the write token. Reading the artifact is fine.
Incremental runs
When the suite gets slow, opt into incremental runs. --changed hands
story selection to Vitest's own git + module-graph analysis, so only stories
affected by a PR are captured. Baselines that weren't selected are carried,
never failed โ a full run on main (or nightly) is the backstop that catches
real deletions.
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # REQUIRED โ a shallow clone trips the guard
- run: pnpm exec svrt run --changed "origin/${{ github.base_ref }}"
Keep the full svrt run on push to main and/or a nightly schedule.
The silent-green trap, guarded. On a shallow clone vitest --changed
would see an empty diff and pass green having tested nothing. svrt checks
the git merge-base itself first: on failure it falls back to a full run
(loud), or exits 3 under --strict. It never passes green without verifying.
fullRunTriggers closes the gap for changes Vitest's module graph can't
see. A change to .storybook/preview.ts selects zero story tests (it reaches
stories through a virtual module), so it forces a full run by default. Add your
global CSS, design tokens, lockfiles, and public/ assets โ anything that
affects rendering without being imported by a component:
{ "fullRunTriggers": ["**/.storybook/**", "src/styles/**", "pnpm-lock.yaml", "public/**"] }
Run svrt plan --changed origin/main to see the decision โ full or
incremental, and why โ before spending a browser run on it.
Caveats
- Watch mode: capture is meant for
svrt run/vitest run; repeated watch-mode reruns thrash the actual directory. - Browser updates: a new Chromium version can shift rendering by a few pixels. Pin Playwright and re-approve baselines when bumping it.
- Multiple browser instances: enable
browserNameSuffixso each browser writes its own key. - Artifact baselines: comparing against a downloaded
mainartifact instead of committed baselines means a regression merged tomainsilently becomes the new baseline. Commit.vrt/expected/so the backstop works.
License
MIT