This repo uses Chromatic to catch unintended visual
changes in components. Chromatic builds the Storybook in apps/docs, renders every
story into snapshots, and diffs them against an accepted baseline. When a snapshot
changes, the build is flagged for human review.
Visual tests do not run on every PR — they're opt-in, so you only spend snapshots when a change is actually visual.
- Open a PR with component or story changes.
- If the change is visual, add the
run-visual-testinglabel. The build runs immediately. - Open the Chromatic link on the PR check and accept or deny each diff.
- Pushed more visual changes? The label was removed after the last build (see below), so re-add it to run again.
- Get the PR reviewed and merge. On merge, the accepted changes become the new
mainbaseline automatically — you don't do anything extra.
If a PR has no visual impact, leave the label off.
run-visual-testing is a one-shot trigger. When a build succeeds, the PR workflow
swaps it for update-visual-testing and drops run-visual-testing. Two reasons:
- One-shot keeps cost down. Without removing it, every later push would re-snapshot. Re-adding the label is a deliberate "yes, test this again."
update-visual-testingis the merge signal. It marks "a PR build passed at some point," and the merge workflow reads it to decide whether to re-baseline. You never add it by hand.
A failed build leaves run-visual-testing in place, so the next push retries and the PR
is never marked.
The split exists because a PR build and a merge build want opposite things from the same snapshots:
- PR build (
.github/workflows/chromatic-pr.yml) runs on the PR head and diffs against the currentmainbaseline, so you can review what changed. - Merge build (
.github/workflows/chromatic-main.yml) runs on the merge commit and becomes the newmainbaseline (CHROMATIC_BRANCH: main+autoAcceptChanges: main), so the next PR doesn't re-flag changes you already accepted.
Doing both in one workflow would either re-baseline on every PR (changes never get reviewed) or never update the baseline (every PR re-flags already-accepted changes).
The merge build also sets CHROMATIC_SHA to the merge commit. Left alone, the action
reports the PR head instead, which a squash merge leaves unreachable from main — the
baseline is then filed under a commit later runs can't resolve, and TurboSnap silently
falls back to the oldest build with a reachable commit and re-snapshots everything.
The merge build only runs when the PR was actually merged and carries
update-visual-testing — a PR closed without merging, or one that never ran Chromatic,
is skipped.
Other behaviour worth knowing:
- TurboSnap (
onlyChanged: true) — only stories affected by the changed files are snapshotted, not the whole Storybook. - PR builds use
cancel-in-progress: trueso a new push cancels the in-flight build and only the latest commit is snapshotted. Merge builds usecancel-in-progress: false— baseline builds must not be cancelled.
Shared by both workflows and the local chromatic script.
| Key | Purpose |
|---|---|
projectId |
Chromatic project this Storybook belongs to. |
storybookBaseDir |
Repo-relative root of the Storybook (./apps/docs) — anchors TurboSnap's change detection. |
storybookConfigDir |
Storybook config dir (./apps/docs/.storybook). |
storybookBuildDir |
Prebuilt static Storybook output (./storybook-static). |
onlyChanged |
Enables TurboSnap by default. |
zip |
Uploads the build as a zip (faster for large Storybooks). |
externals |
Non-source assets (fonts, css, favicons) that, when changed, should invalidate TurboSnap rather than be silently skipped. |
projectToken is not in this file — it comes from the CHROMATIC_PROJECT_TOKEN
repository secret (CI) or your environment (local).
Storybook also runs interaction/render tests via Vitest in a real browser (Playwright/Chromium):
cd apps/docs
pnpm test-storybook # vitest --project=storybookThese verify behaviour/rendering and run independently of Chromatic's pixel diffing.