Capturing visual baselines at multiple breakpoints
This page is part of the responsive viewport testing section of the visual regression guide, which covers capturing a component at more than one width.
The specific problem: you have chosen five widths and want a baseline for each. A naive loop produces one set of images that each width overwrites in turn, and a run five times longer than before.
Problem statement
Multiplying a suite by five is not simply doing the same thing five times. Two things break that were fine with one width: the file naming, because every capture now needs a width in its identity, and the runtime, because five sequential passes take five times as long.
The symptom of the first is a baseline directory with the original file count after a run that captured five times as many images. The symptom of the second is a visual job that stops fitting inside a pull-request window.
Root cause
Playwright derives a snapshot path from the test file, the test title, and the argument passed to toHaveScreenshot. None of those includes the viewport, so five captures of the same story resolve to the same path and the last one wins.
The runtime problem has the same shape: a for loop over widths inside one test runs in one worker, sequentially. The runner’s own parallelism is organised around projects, so widths expressed as projects are distributed and widths expressed as a loop are not.
Minimal reproduction
// five captures, one filename, four silently discarded
for (const width of [360, 767, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
await expect(page.locator('#storybook-root')).toHaveScreenshot('order-card.png');
}
Step-by-step fix
1. Declare each width as a project
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { VIEWPORTS } from './test/viewports';
export default defineConfig({
projects: VIEWPORTS.map((vp) => ({
name: vp.name,
use: {
viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: 1,
reducedMotion: 'reduce',
},
})),
});
What this does: the runner now schedules five independent projects, which it distributes across workers rather than executing in sequence.
2. Put the project name in the snapshot path
export default defineConfig({
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}',
});
What this does: gives each width its own directory, so the five captures of one story are five distinct baselines rather than one file written five times.
3. Write the test once, with no width in it
test('order card', async ({ page }) => {
await page.goto('/iframe.html?id=ordercard--default');
await expect(page.locator('#storybook-root')).toHaveScreenshot('order-card.png', {
animations: 'disabled',
});
});
What this does: the viewport comes from the project, so the test says what it captures and the config says at which widths. Adding a sixth width requires no change to any test.
4. Generate the baselines deliberately
npx playwright test --update-snapshots
What this does: writes one image per story per project. Review a sample of the narrow ones before committing — the narrow arrangement is the one nobody has looked at, and a first run often reveals a genuine overflow bug rather than a baseline worth keeping.
5. Split the pull-request job from the full matrix
# fast feedback: the two widths that catch most reflow bugs
npx playwright test --project=mobile --project=desktop
# nightly: everything
npx playwright test
What this does: keeps the gating job proportionate while still covering the full matrix on a cadence where a batch of diffs is reviewable.
Verification
The count is the check: five projects times the story count. A total equal to the story count means the path template is missing, and four fifths of the captures are being discarded.
Edge cases and caveats
-
Stories that only exist at one width. A story documenting a mobile-only control has no meaningful desktop baseline. Tag it and restrict it to the relevant project rather than generating an image nobody will look at.
-
Per-project thresholds. Narrow layouts pack more content into fewer pixels, so the same absolute change is a larger fraction of the image. A slightly looser ratio on the narrowest project is defensible; the same value everywhere usually is not.
-
Baseline review load. Two hundred and ten baselines is a lot to review the first time. Generate them in one pull request that changes nothing else, so every image in it has one explanation.
Naming baselines so a diff list is readable
With five widths the baseline directory holds several hundred files, and the names are what a reviewer navigates by. Three conventions keep that list usable.
Put the width first in the directory, not in the filename. __screenshots__/mobile/order-card.png groups every narrow capture together, which is how a reviewer wants to read them when a narrow-only regression appears. order-card-mobile.png scattered among the desktop images does not group at all.
Keep the story id in the filename verbatim. The same string appears in the Storybook URL, the test-runner report and the Chromatic diff list, so a reviewer can paste it between tools. Prettifying it into a human sentence breaks that chain for the sake of a name nobody reads in isolation.
Do not encode the browser in the filename when it is already a project. Playwright’s path template can include both the project and the browser, and doing both produces chromium-mobile/chromium-mobile-order-card.png. Pick one place for each dimension.
The payoff is a directory that can be reasoned about with ordinary tools. git status after a token change shows whether the diff spans every width or one, before any image is opened — which is usually enough to classify the change and decide how much review it needs.
FAQ
Should each width be a project or a separate test file?
A project. The test describes what is captured and the project describes the conditions, which keeps the two independent: adding a width is a config change, and adding a story is a test change. Separate files per width duplicate every test and guarantee that one of the copies eventually falls behind.
Do all five projects need to run on every pull request?
No, and running them all is usually the wrong default. Two widths — the narrowest and one desktop — catch the majority of reflow regressions, and the remaining three can run nightly. Promote a width to the pull-request set when it has demonstrably caught something the two did not.
How do I stop the narrow baselines from being generated wrong the first time?
Look at them. The narrow arrangement is typically the least-reviewed rendering in the whole system, so the first generation run is often the first time anyone has seen it — and a genuine overflow bug baselined as correct is very hard to notice later. Reviewing the first narrow set is an hour that pays for itself.
Related
- Responsive Viewport Testing — the parent section on capturing at more than one width
- Dynamic Content Masking — stabilising a capture before multiplying it by five
- Cross-Browser Matrix — the other axis that multiplies the baseline count
- Baseline Management — storing and reviewing the baselines these captures create