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.
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.
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 |
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.
Related
- Dynamic Content Masking — stabilising captures before multiplying them by five
- Cross-Browser Matrix — the other axis that multiplies the baseline count
- Baseline Management — storing and reviewing the baselines this section creates
- Test Parallelization & Sharding — keeping a multiplied suite inside a pull-request window
- Visual Regression & Snapshot Strategies — the parent guide this section sits under