Responsive viewport visual testing

This section sits under visual regression and snapshot strategies and covers capturing a component at more than one width. Responsive layout is the failure mode a single desktop baseline is structurally unable to see: a card that reads perfectly at 1440 px can overlap its own controls at 360 px, and nothing in a desktop-only suite will ever notice.

Prerequisites

Choosing widths instead of sweeping them

The instinct on first setting this up is to capture at regular intervals — every hundred pixels from 320 to 1920 — which produces seventeen baselines per component, of which perhaps four show anything different.

A layout only changes at its breakpoints. Between them it stretches, and a stretched layout is not a new arrangement: it is the same arrangement at a different width, and a snapshot of it proves nothing the neighbouring one did not. The widths worth capturing are therefore the ones where something actually changes, plus the extremes.

Which widths are worth capturing Six rows. Three hundred and sixty pixels proves the narrowest supported layout. Seven hundred and sixty-seven and seven hundred and sixty-eight pixels sit either side of the first breakpoint and prove the transition. One thousand and twenty-four proves the tablet arrangement and fourteen hundred and forty the widest common desktop. Each costs one baseline. Sweeping every hundred pixels costs eleven or more baselines and proves almost nothing the five chosen widths do not. Width What it proves Cost per component 360 px the narrowest supported layout 1 baseline 767 px just below the first breakpoint 1 baseline 768 px just above it — the transition 1 baseline 1024 px the tablet arrangement 1 baseline 1440 px the widest common desktop 1 baseline every 100 px almost nothing new 11+ baselines

Three widths is a reasonable floor for a component library: the narrowest supported width, one desktop width, and one width immediately either side of the breakpoint most components use. Five is comfortable. Beyond that, each addition should be justified by a specific arrangement it is the only width to show.

Deciding whether a width earns a baseline A decision node asking what changes at a candidate width. If the layout reflows, capture both sides of the breakpoint. If a control is hidden, capture it and assert the DOM change as well, since the two failures mean different things. If text wraps differently, capture it, because truncation bugs live there. If nothing visible changes, skip the width — it has earned no baseline. What changes at this width? Capture both sides 767 and 768 the layout reflows Capture it and assert the DOM too a control is hidden Capture it truncation lives here text wraps differently Skip it no baseline earned nothing visible

Step-by-step implementation

Step 1 — Name the widths once, in code

// test/viewports.ts — one source, imported by every spec and story
export const VIEWPORTS = [
  { name: 'mobile',  width: 360,  height: 780 },
  { name: 'below-md', width: 767, height: 900 },
  { name: 'md',      width: 768,  height: 900 },
  { name: 'tablet',  width: 1024, height: 900 },
  { name: 'desktop', width: 1440, height: 900 },
] as const;

Verify it works: importing this in both the Playwright spec and the Storybook viewport configuration means a width added here appears in both, which is the property that stops the two drifting.

Step 2 — Capture the same story at each width

import { VIEWPORTS } from './viewports';

for (const vp of VIEWPORTS) {
  test(`OrderCard at ${vp.name}`, async ({ page }) => {
    await page.setViewportSize({ width: vp.width, height: vp.height });
    await page.goto('/iframe.html?id=ordercard--default');
    await expect(page.locator('#storybook-root')).toHaveScreenshot(`order-card-${vp.name}.png`, {
      animations: 'disabled',
    });
  });
}

Verify it works: the baseline directory should gain one file per width, and their dimensions should match the viewport widths you declared.

Step 3 — Pin the device pixel ratio

// playwright.config.ts
export default defineConfig({
  use: { deviceScaleFactor: 1, reducedMotion: 'reduce' },
});

Verify it works: baselines captured on a retina laptop and on a Linux runner should have identical pixel dimensions. Differing dimensions mean the scale factor is being inherited from the host.

Step 4 — Set the viewport before navigation

// resizing after load means the component mounted at the wrong width first
await page.setViewportSize({ width: 360, height: 780 });
await page.goto('/iframe.html?id=ordercard--default');

Verify it works: a component that reads its own width on mount — a container-aware layout, a virtualised list — should render its narrow arrangement rather than the desktop one briefly followed by a reflow.

Step 5 — Restrict the wide matrix to the components that need it

// tag the components whose layout actually reflows
test.describe('responsive @wide-matrix', () => { /* … */ });
# primitives at one width on every PR; layout components at five
npx playwright test --grep-invert @wide-matrix
npx playwright test --grep @wide-matrix

Verify it works: the pull-request job’s baseline count should be a fraction of the full matrix, and the two jobs together should cover every component at least once.

Step 8 — Decide what a full-page capture means at each width

Component captures are width-independent in one useful sense: the story root is the same element everywhere, so only its rendered arrangement changes. A full-page capture is not, and the difference catches people out.

// what "the page" means depends on the viewport height as well as the width
await expect(page).toHaveScreenshot('checkout-mobile.png', {
  fullPage: true,          // the whole scrollable document, not the visible box
  animations: 'disabled',
});

A fullPage capture at 360 pixels wide is tall and narrow, and at 1440 short and wide, so the two images share almost no structure. That is correct, and it means the two baselines have to be reviewed as separate artefacts rather than compared with each other.

Verify it works: the captured image height should match the document’s scroll height at that width, not the viewport height. If they match the viewport instead, fullPage is not being applied — a common result of passing it to a locator’s screenshot call, where it has no effect, rather than to the page’s.

Configuration reference

Setting Tool Effect
setViewportSize Playwright Sets the width and height before navigation
deviceScaleFactor Playwright Pins the device pixel ratio; must be explicit
use.viewport Playwright Project-level default viewport
projects Playwright One project per viewport, run in parallel
viewports Chromatic Widths captured per story
chromatic.viewports Story parameter Per-story override of the width list
parameters.viewport Storybook The width the browsable story opens at
How the baseline count multiplies Horizontal bars for a library of forty-two components. Capturing at one width gives forty-two baselines. Three widths give one hundred and twenty-six. Five widths give two hundred and ten, highlighted as a typical target. Five widths across three browser engines gives six hundred and thirty. 42 components, per configuration 1 width 42 baselines 3 widths 126 baselines 5 widths 210 baselines 5 widths x 3 engines 630 baselines

Step 6 — Run widths as parallel projects rather than a loop

A for loop over viewports runs them sequentially inside one worker. Declaring them as Playwright projects lets the runner distribute them, which turns a five-times-longer job into a five-times-wider one.

// playwright.config.ts
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 },
  })),
  snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{arg}{ext}',
});

Verify it works: the report should list one project per width, each with the full story count, and the snapshot directory should hold one folder per width. The path template is the important detail — without it every project writes to the same filename and the last one wins.

Step 7 — Assert the DOM change as well as the pixels

A width that hides a control changes both the layout and the accessibility tree, and only one of those is visible in an image.

test('nav collapses to a menu button below md', async ({ page }) => {
  await page.setViewportSize({ width: 767, height: 900 });
  await page.goto('/iframe.html?id=sitenav--default');

  await expect(page.getByRole('button', { name: /menu/i })).toBeVisible();
  await expect(page.getByRole('navigation')).not.toBeVisible();
});

Verify it works: widen the viewport to 768 in the same test and the assertions should invert. A control that is visually hidden but still in the tab order fails this assertion while passing the snapshot, which is exactly the bug the pixel tier cannot see.

Common pitfalls

Resizing after navigation. A component that measures itself on mount sees the old width, and the resulting baseline records a layout that no real user gets. Set the viewport first.

Forgetting the height. A short viewport truncates a full-page capture and changes what is above the fold. Pin both dimensions, not just the width.

Sweeping widths. Seventeen baselines per component is seventeen images to review when a token changes, and sixteen of them say the same thing.

Capturing every component at every width. A Badge does not reflow. Restricting the wide matrix to components with a responsive arrangement keeps the count proportional to the risk.

Unpinned device pixel ratio. A retina developer machine produces baselines at twice the pixel dimensions of the CI runner’s, so every comparison fails on size before it ever compares content.

Reading a responsive diff

A multiplied suite fails in a characteristic way: a single change produces several diffs, and the pattern across them is more informative than any one of them.

All widths diff identically. The change is width-independent — a colour, a border radius, a font weight. Review one and approve the set; the others carry no extra information.

One width diffs and the others do not. The change is specific to that arrangement, which usually means a breakpoint-scoped rule was edited. This is the most valuable pattern the matrix produces, and the one a single-width suite would have missed entirely.

Adjacent widths diff in opposite directions. Something moved across a breakpoint: a rule that used to apply at 768 now applies at 767, or vice versa. Capturing either side of each breakpoint is what makes this visible, and it is worth the extra baseline precisely because this class of bug is otherwise invisible until a user reports it.

Only the narrowest width diffs, and by a lot. Almost always overflow — content that no longer fits and has started wrapping, truncating, or pushing a control off-screen. Worth treating as a probable regression rather than an intended change, because narrow-width overflow is rarely deliberate.

Because the patterns are this legible, it is worth presenting the diffs grouped by story rather than by width. A reviewer looking at one component’s five images side by side can classify the change in seconds; the same five images scattered through a list ordered by filename cannot be compared at all.

Integration point

Responsive capture multiplies everything upstream of it, which is why it comes late in a visual programme. Every unstable element covered in dynamic content masking now produces five diffs instead of one, and every threshold decision in tolerance thresholds applies per width. The extra baselines land in whatever storage baseline management prescribes, and the extra runtime is what makes test parallelization and sharding worth setting up. Where a width hides a control rather than rearranging it, pair the capture with a DOM assertion from component testing fundamentals — the two failures mean different things.

FAQ

How many widths should a component library capture at?

Three as a floor, five as a comfortable target, and more only when a specific arrangement is not visible at any of them. The number that matters is not the width count but whether each width shows an arrangement the others do not — a width that always diffs identically to its neighbour is paying storage and review cost for no information.

Should I use device presets instead of raw widths?

Raw widths are clearer for a component library, because a preset bundles a width with a scale factor, a user agent and touch support, and only one of those affects layout. Presets earn their place when you are testing a whole application where touch behaviour and user-agent sniffing matter. For a Button, width: 360 says exactly what is being tested.

Do I need a separate baseline per width, or can one be resized?

One per width. A resized image is a different rasterisation, so comparing it against a capture at the true width fails on interpolation artefacts long before it fails on a real change. The baselines are cheap; the false failures are not.

What about testing between breakpoints?

Usually unnecessary, with one exception: layouts using clamp or fluid typography change continuously rather than at a breakpoint, so an intermediate width can show a state neither neighbour does. For those components, one mid-range width is worth adding — chosen where the fluid value is at its least forgiving, not at a round number.

Should the responsive matrix run on every pull request?

Run the narrow width and one desktop width on every pull request, because those two catch most reflow regressions, and defer the remaining widths to a nightly or pre-release job. The reasoning is the same as for browser engines: the marginal widths find fewer unique failures per baseline, so they belong on a cadence where a larger batch of diffs is acceptable to review at once.

How do I keep the extra baselines from bloating the repository?

Keep the images small — capture the component, not the page around it — and restrict the wide matrix to components that reflow. A 360-pixel-wide card is a few kilobytes; a full-page desktop shot is two orders of magnitude larger, and capturing pages at five widths is what actually fills a repository. If the total still grows past a few hundred megabytes, Git LFS is the next step rather than deleting coverage.

Can container queries replace viewport-based capture?

For components that use them, largely yes — and the capture technique changes with them. A component sized by its container does not care what the viewport is, so varying the viewport proves nothing; varying the wrapper’s width proves everything. In a mixed codebase both matter: the page-level layout still responds to the viewport, and the components inside it respond to whatever width that layout hands them.

What height should the viewport be?

Tall enough that the component is not clipped, and consistent across runs. For a component capture the height barely matters, since the shot is scoped to the story root. For a full-page capture it decides what is above the fold and therefore what a fullPage: false screenshot contains, so it needs pinning as deliberately as the width.