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. - ๐ชถ 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 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.
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. One step captures, compares, and fails the job on visual findings. Pin the capture environment so baselines don't drift with fonts.
# .github/workflows/vrt.yml
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: 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).
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