Systems16 July 20268 min readNULLLAB

The case against breakpoints

Fixed breakpoints guess at a viewport that no longer exists. Fluid type and container queries let a component respond to its own space instead.

The case against breakpoints — cover
A system tuned to the space it's given, not to a list of guessed screen widths.

Every design system we've inherited from another studio has the same fossil buried in it: a list of breakpoints, usually five or six, usually named after devices that stopped mattering years ago. 320, 768, 1024, 1440. Numbers chosen because they felt reasonable in a meeting, then defended forever because changing them touches everything. We stopped writing them. Not because breakpoints are wrong in principle, but because two things arrived that make most of them unnecessary — fluid type, and CSS container queries — and once you have both, a fixed list of screen widths starts to look like exactly what it was: a guess, encoded as a spec.

Breakpoints guess. Containers know.

A breakpoint is a bet about the viewport. It says: at 768px and below, assume a phone; above, assume enough room for two columns. The bet was reasonable when the viewport and the layout were the same thing — one browser window, one column count. It stopped being reasonable the moment components started living inside sidebars, cards, split panes and embedded widgets, where the available width has nothing to do with the size of the screen. A card in a 320px sidebar on a 2560px monitor still gets the 'desktop' styles, because the media query only ever asked one question, and it was the wrong one.

The tell is in the workarounds. Teams end up wrapping components in JavaScript ResizeObserver hacks, or shipping two versions of the same card and switching between them with a prop, or simply accepting that the sidebar card looks slightly broken and calling it a known issue. All of that is compensation for asking the viewport a question that only the container can answer.

Container queries fix the actual question. @container asks how much space is this element sitting in, not how wide is the window. A card component gets one set of rules and applies them correctly whether it's the hero of the page or a thumbnail in a rail, because the query travels with the box, not the browser.

Fluid type ends the jump

The second fossil is the type scale that jumps. A heading is 32px, then at the 1024 breakpoint it becomes 48px, and anyone resizing a window through that boundary watches the text snap. clamp() removes the snap entirely: clamp(2rem, 1.2rem + 2.5vw, 3.5rem) gives you a minimum, a fluid middle governed by viewport width, and a maximum, and the browser interpolates between them continuously. There is no boundary to defend in a design review, because there is no boundary.

This does more than smooth an edge case. It collapses an entire category of decision — what size is this heading at each breakpoint — into a single, three-value formula that a design token can hold. Our type scale is a handful of clamp() expressions, not a table of pixel values crossed with a table of screen widths. Fewer decisions, fewer places for the system to drift from itself.

The same logic extends past type: fluid spacing with clamp() on padding and gaps, fluid container widths with min(), clamp() on grid-template-columns. Once you're comfortable letting a formula replace a lookup table for type, the rest of the spacing system follows the same pattern almost by default.

Container queries flip the model

The deeper shift is architectural, not cosmetic. A media-query-based system styles the page from the outside in: the viewport dictates, and every component downstream inherits the decision whether it applies to that component or not. A container-query-based system styles from the component outward: each component declares its own rules for the space it's given, and it doesn't need to know or care what device is holding the browser.

A component that only knows its own container is a component you can drop anywhere and trust.

That portability is the actual prize. A card, a stat block, a pull-quote module — built once with container queries — behaves correctly in a full-width hero, a two-up grid, a cramped sidebar and a modal, without a single prop threaded through to tell it which context it's in. The component became context-aware without becoming context-dependent, which is the property every reusable design-system piece is theoretically supposed to have and almost never does.

Where breakpoints still earn their keep

None of this retires the media query. Container queries answer 'how much space do I have', which is the wrong question for decisions that are genuinely about the device: pointer versus touch target sizing, hover availability, prefers-reduced-motion, prefers-color-scheme, print styles. Those are viewport- and environment-level facts, and a media query is still the correct tool for them.

There's also a structural limit worth knowing before you reach for container queries everywhere: a container can't query its own size in a property that would change that size — you can't set an element's width based on a container query on that same element's inline axis. It's a narrow constraint in practice, but it's the one place the model still needs a fixed ancestor or a media query to break the loop.

So the rule we actually use: reach for a media query when the decision is about the device or the environment. Reach for a container query when the decision is about the space a component has to work with. Almost every layout decision turns out to be the second kind, which is why breakpoints have shrunk to a handful of environment-level rules in our systems rather than the backbone of the whole thing.

How we build it now

In practice: every card, panel and content module gets container-type: inline-size on its wrapper and its internal rules live in @container blocks scoped to that wrapper's own name. Type and spacing are clamp() expressions defined once as tokens, not per-breakpoint overrides. The handful of true media queries we keep are named for what they check — pointer: coarse, prefers-reduced-motion, prefers-color-scheme — never for a pixel number, because a pixel number is a guess about a device we've already stopped trusting.

The payoff shows up when a client asks for the same card component inside a new context we didn't design for — a dashboard widget, an embedded partner site, a narrower content column. In the old model, that request opened a ticket. In this one, it already works, because we never built the component to know what device it was on. We only ever taught it to read the room it's actually in.

Frequently asked questions

What's the difference between a media query and a container query?

A media query reads facts about the viewport or device: window width, pointer type, colour scheme preference. A container query reads the size of a specific ancestor element, regardless of the viewport. Use media queries for device- and environment-level decisions; use container queries for how a component should look given the space it's actually been placed in.

Do container queries replace breakpoints entirely?

No. Breakpoints still make sense for viewport- and device-level facts like touch versus pointer input, reduced-motion preference or print output. What container queries replace is using the viewport as a proxy for a component's available space, which was always an approximation and breaks as soon as a component moves into a sidebar, card or embed.

Is clamp() reliable enough for production type scales?

Yes. clamp(min, preferred, max) has full support across current evergreen browsers and degrades gracefully; older browsers simply see the fallback value if one is supplied. We use it for every fluid type and spacing token on production sites, and it removes the visual snap that fixed-breakpoint type scales have at every boundary.

Keep reading

Precision over polish — cover

Systems6 min read

Precision over polish

Systems, not pages — cover

Systems7 min read

Systems, not pages