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.

One width list, five parallel projects Four stages left to right. A single shared viewports file, highlighted, defines the width list. Playwright turns it into one project per width. Each project captures the story root only. The snapshot path template writes each project's images into its own folder, so filenames cannot collide. The path template is what stops the projects overwriting each other viewports.ts one width list playwright projects one per width capture story root only __screenshots__/<width>/ one folder each

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

Baselines generated per width, in separate folders A Playwright update run. Each of five projects — mobile, below-md, md, tablet and desktop — writes forty-two baseline images into its own screenshots folder. Two hundred and ten baselines are produced across five projects in one minute forty-eight seconds. playwright test --update-snapshots [mobile] 42 written __screenshots__/mobile/ [below-md] 42 written __screenshots__/below-md/ [md] 42 written __screenshots__/md/ [tablet] 42 written __screenshots__/tablet/ [desktop] 42 written __screenshots__/desktop/ 210 baselines, 5 projects, 1m 48s

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

Five ways a multi-width setup goes wrong Five rows. Omitting the project name from the snapshot path template yields forty-two baselines rather than two hundred and ten, because the projects overwrite each other. Resizing after navigation captures the desktop layout at a narrow width. An unpinned device pixel ratio produces a size mismatch on every comparison. Leaving the height at its default clips full-page captures. Looping over widths instead of declaring projects makes the run five times longer in a single worker. Mistake Symptom Fix No path template 42 baselines, not 210 add {projectName} Resize after goto desktop layout at 360 set viewport first Unpinned DPR size mismatch on every shot deviceScaleFactor: 1 Height left default clipped full-page shots pin both dimensions Loop instead of projects 5x runtime, 1 worker declare projects
  • 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.