Testing container queries with visual regression
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: a card uses @container queries, so it rearranges based on the width of its wrapper rather than the window. Capturing it at five viewport widths produces five identical images, and the arrangements you meant to test are never rendered.
Problem statement
Container queries decouple a component’s layout from the viewport, which is the point of them: the same card can sit in a wide main column and a narrow sidebar and lay itself out correctly in both without knowing anything about the window.
That decoupling breaks a viewport-driven visual suite. Setting the viewport to 360 pixels does not narrow the story’s wrapper — Storybook renders the story into a root element that fills the available space — so the component sees the same container width it always did, and the narrow arrangement is never captured.
The symptom is five baselines that are byte-identical, and a suite that reports full responsive coverage while testing one arrangement.
Root cause
@container (max-width: 400px) resolves against the nearest ancestor declaring container-type, not against the viewport. Nothing Playwright does to the window changes that ancestor’s width unless the layout between them happens to be viewport-driven too.
The fix is to vary what the component actually reads. That means rendering it inside a wrapper of a known width, and treating that width as the axis — exactly as component variants treats size or tone.
Minimal reproduction
/* Card.css — responds to its container, not the window */
.card-wrap { container-type: inline-size; }
@container (min-width: 400px) {
.card { display: grid; grid-template-columns: 96px 1fr; }
}
// five viewports, five identical images
for (const width of [360, 767, 768, 1024, 1440]) { /* … */ }
Step-by-step fix
1. Make the container width a story argument
// Card.stories.tsx
const meta = {
component: Card,
decorators: [
(Story, ctx) => (
<div style={{ containerType: 'inline-size', width: ctx.parameters.containerWidth ?? '100%' }}>
<Story />
</div>
),
],
};
What this does: gives every story a container whose width the story itself declares, so the component’s query has something real to respond to.
2. Declare one story per arrangement
export const Narrow: Story = { parameters: { containerWidth: 240 } };
export const Medium: Story = { parameters: { containerWidth: 320 } };
export const Wide: Story = { parameters: { containerWidth: 400 } };
export const Widest: Story = { parameters: { containerWidth: 640 } };
What this does: turns the container width into a named, browsable state — which also means a reviewer can see all four arrangements without resizing anything.
3. Capture each at one viewport
test.use({ viewport: { width: 1280, height: 900 } });
for (const name of ['narrow', 'medium', 'wide', 'widest']) {
test(`card, container ${name}`, async ({ page }) => {
await page.goto(`/iframe.html?id=card--${name}`);
await expect(page.locator('#storybook-root')).toHaveScreenshot(`card-${name}.png`, {
animations: 'disabled',
});
});
}
What this does: the viewport is now irrelevant and held constant, which is honest — it is not what the component reads.
4. Capture either side of each container breakpoint
export const JustBelow: Story = { parameters: { containerWidth: 399 } };
export const JustAbove: Story = { parameters: { containerWidth: 400 } };
What this does: proves the query fires where it is declared to. A rule written with max-width: 400px instead of min-width inverts the arrangement, and only a pair of captures either side reveals it.
5. Keep one viewport test for the page-level layout
The layout that decides how wide the container is is viewport-driven, so it still needs viewport-width coverage. Test the page at viewport widths and the component at container widths, rather than trying to make one axis serve both.
Verification
The check that the setup is real is that two adjacent stories differ. If JustBelow and JustAbove produce identical images, the container query is not firing — usually because container-type is missing from the wrapper, or because a layout between the wrapper and the component is establishing a new containment context.
Edge cases and caveats
-
cqwunits. A component sized in container-query units scales continuously with the container, so intermediate widths do show something new. For those, add a mid-range capture; for components that only switch at a breakpoint, the pair either side is enough. -
Nested containers. A container inside a container resolves against the nearest one. A decorator adding a wrapper can accidentally become the container the component reads, which makes the outer width irrelevant — declare
container-typein exactly one place. -
Browser support. Container queries are widely supported, but a browser without them falls back to whatever the unqueried CSS specifies. If your matrix includes an older engine, that fallback is a distinct arrangement and deserves its own baseline.
FAQ
Should container widths be stories or a viewport matrix?
Stories. The width the component responds to is an input to the component, exactly like a prop, so expressing it as a story keeps it visible in Storybook, gives it a name, and lets a reviewer browse the arrangements. A viewport matrix expresses it as an environmental condition, which for a container-driven component it simply is not.
How do I know whether a component is container-driven or viewport-driven?
Grep its stylesheet for @container and cqw. In a mixed codebase most components are viewport-driven and a handful are not, and the handful are precisely the ones whose responsive coverage is silently empty. It is worth auditing once: any component with a container query and no container-width stories has an untested arrangement.
Can I set the container width from the Playwright test instead of the story?
You can inject a style before capture, and it works. It also means the arrangement only exists inside the test — a reviewer browsing Storybook never sees it, and neither does the docs page. Putting the width in the story makes the same arrangement available to every consumer of the story, which is the reason for doing it in the story in the first place.
What happens to these stories when the design system drops a container breakpoint?
The stories keep rendering, at widths that no longer mean anything — which is the same failure mode as a variant story for a variant that was removed. Deriving the story list from the same constant the CSS is generated from is the durable answer where the toolchain allows it; where it does not, a periodic check that each container-width story still straddles a real breakpoint takes minutes and prevents a set of baselines that quietly assert nothing.
Does this technique work with element queries polyfills?
It works with anything that reads a wrapper’s width, including the older polyfills, because the technique is not about the query mechanism — it is about making the wrapper’s width an explicit input. A polyfill that observes the element and adds classes responds to a fixed-width wrapper exactly as a native container query does.
Related
- Responsive Viewport Testing — the parent section on capturing at more than one width
- Capturing Visual Baselines at Multiple Breakpoints — the viewport-driven equivalent of this technique
- Component Variants — expressing container widths as a variant axis
- Dynamic Content Masking — stabilising the captures this multiplies