Skip to content

Responsive Layout: Designing Without Breakpoints

Breakpoints were a workaround for a layout system that could not adapt on its own. CSS can now express most responsive behaviour directly, and the result needs far less code.

Share
Abstract visualisation of a modular grid reflowing continuously across changing widths

We built the standard responsive toolkit around a limitation. CSS had no way for a layout to reason about the space available, so we approximated it: pick a set of screen widths, write a separate layout for each, and switch between them with media queries. Given the tools of the time, it was the correct approach.

It is no longer the only one, and increasingly not the best one. CSS can now express "fit as many of these as will comfortably fit" and "adapt to the width of your container" directly, which means many media queries in a typical stylesheet solve problems the language handles natively.

This is about that shift: what replaces breakpoints, where media queries still belong, and what a modern responsive layout actually looks like. It is deliberately about layout mechanics. The device-specific side of this, keyboards, safe areas, viewport units and touch, is a separate problem that I covered in designing for mobile.

Why device breakpoints stopped working

The original model assumed devices clustered at predictable widths: phone, tablet, desktop. Pick three numbers, done.

That assumption has failed in several directions at once. Screen sizes form a continuum, not clusters. Foldables change width mid-session. Desktop windows are resized constantly and often aren't maximised. Android's resizability rules mean an app or web view can be handed an arbitrary window size, which I covered in detail when writing about testing for resizability.

But the deeper flaw was never about which numbers you pick. Viewport width tells you nothing about the space a given component has. A card in a 300px sidebar and the same card in a 900px main column have identical viewport width and completely different layout needs. No choice of breakpoints resolves that, which is why component libraries accumulate modifier classes like card--compact that exist purely to pass down information the component should have been able to work out for itself.

The intrinsic toolkit

Three functions and one grid pattern replace most breakpoint work.

min(), max() and clamp() let a single value adapt. clamp(min, preferred, max) is the workhorse:

.container {
  inline-size: clamp(20rem, 90vw, 75rem);
  padding-inline: clamp(1rem, 5vw, 3rem);
}

That container is never narrower than 20rem, never wider than 75rem, and fills 90 per cent of the viewport in between. Expressed with breakpoints, it would take three media queries and still step abruptly rather than scaling smoothly.

Then the pattern that does the heaviest lifting, sometimes called RAM, for repeat, auto-fit, minmax:

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
  gap: 1.5rem;
}

This says: fit as many columns as will hold 18rem, and share leftover space equally. Four across on a wide screen, two on a tablet, one on a phone, with no breakpoints and no assumptions about devices.

The min(18rem, 100%) inside minmax is the part that is easy to omit and important to include. Without it, on a viewport narrower than 18rem, the track refuses to shrink, and the grid overflows horizontally. That single omission is the most common cause of unexpected horizontal scroll on small phones.

Worth knowing the difference between auto-fit and auto-fill, since they look identical until they aren't. auto-fit collapses empty tracks so the existing items stretch to fill the row. auto-fill keeps the empty tracks, so three items in a four-track row stay at their natural width and leave a gap. Most of the time you want auto-fit, but if you need consistent item widths regardless of count, auto-fill is the one.

Grid or flexbox

A short heuristic, since this generates more debate than it deserves.

Use grid when you want the container to control the arrangement, which is most page-level and card-grid layouts. Use flexbox when you want items to distribute along one axis and their content to influence sizing, which covers toolbars, button groups, tag lists, and navigation.

Flexbox has its own breakpoint-free pattern worth knowing:

.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.toolbar > * {
  flex: 1 1 12rem;
}

Items sit in a row while there is room and wrap when there is not, with the 12rem basis deciding when. Again, no media query.

And subgrid solves the alignment problem that used to require fixed heights: child elements of a card can align to tracks defined on the parent grid, so titles line up across a row even when their content differs in length.

Fluid typography, carefully

Type can scale with the same technique, and it removes another cluster of breakpoints:

:root {
  --step-0: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --step-1: clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem);
  --step-2: clamp(1.5rem, 1.3rem + 1vw, 2.25rem);
}

Two things to get right here, because fluid type has an accessibility trap.

Always include a rem component in the middle value, as above, rather than using a pure viewport unit. A size defined only in vw does not respond to the browser's text size setting, which breaks zoom for anyone who relies on it. Mixing rem and vw keeps the user's preference influential.

Also check that your text still scales to 200 per cent without losing content or function, which is a WCAG requirement. A low maximum can silently cap growth and cause it to fail.

For line length, the constraint is content rather than viewport:

.prose { max-inline-size: 65ch; }

The ch unit is relative to the font's character width, so the measure stays in the comfortable 45 to 75 character range regardless of font size or screen.

Container queries are the actual replacement

Everything above adapts to available space implicitly. Container queries let a component ask explicitly, which is what finally solves the sidebar problem.

.card-wrapper {
  container: card / inline-size;
}

@container card (inline-size > 30rem) {
  .card {
    display: grid;
    grid-template-columns: 12rem 1fr;
  }
}

The component now behaves correctly wherever it is placed, with no knowledge of the page around it and no modifier classes. Drop it in a narrow column, and it stacks; drop it in a wide one, and it goes horizontal.

Container units complete the picture. cqi resolves against the container's inline size rather than the viewport, so type and spacing inside a component scale with the component:

.card__title { font-size: clamp(1rem, 0.8rem + 1.4cqi, 1.5rem); }

Two practical constraints. Setting container-type: inline-size establishes containment, so the element stops sizing itself based on its children's inline dimensions, which can surprise you at first. And an element cannot query itself, only an ancestor, so components generally need a wrapper to act as the container. That wrapper is a small structural cost and worth paying.

Style queries extend the same idea to non-dimensional context, letting a subtree respond to a custom property set higher up, which replaces a lot of theming and density modifier chains. I went further into how this fits into a wider stylesheet in the piece on CSS architecture.

Media queries still have a job

None of this makes media queries obsolete. It narrows them to what they are actually good at: properties of the environment rather than of the space.

Capability queries matter more than width. @media (hover: hover) gates hover styling, since a touch device applies hover on tap and leaves controls stuck in a highlighted state. @media (pointer: coarse) tells you the primary input is imprecise, which should drive target sizing far more than screen width does.

Preference queries are non-negotiable. prefers-reduced-motion for people affected by vestibular disorders, prefers-color-scheme for theming, and prefers-contrast where it applies.

And genuine environment cases: print stylesheets, orientation where it materially changes a layout, and page-level structural shifts, such as a sidebar that only exists above a certain width, which are legitimately a viewport concern rather than a component one.

The rule of thumb: if the question is "how much room does this thing have", use a container query or an intrinsic value. If the question is "what kind of environment is this", use a media query.

Images and media

Media needs two things in a fluid layout.

Reserve the space, so nothing shifts as it loads:

img, video {
  max-inline-size: 100%;
  block-size: auto;
}

.media {
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

aspect-ratio with object-fit: cover gives you a consistently proportioned box that crops rather than distorts, which is what you want for user-supplied images of unpredictable dimensions.

Then serve an appropriately sized file, which is srcset and sizes and is where responsive images most often go wrong, since a sizes value that does not reflect the real rendered width makes the browser download the wrong file with complete confidence. That has enough detail to warrant its own treatment, which it got in the image optimisation piece.

One correction worth making in passing: JPEG XR is not a responsive image format to consider. It was a Microsoft format; support was removed years ago, and the current answer is AVIF with a WebP fallback.

Logical properties

Worth adopting as a default habit rather than an internationalisation afterthought.

margin-inline-start rather than margin-left, padding-block rather than padding-top and padding-bottom, inline-size rather than width. In a left-to-right language, they behave identically to the physical properties, so there is no cost. In a right-to-left one they flip correctly without a separate stylesheet, and in vertical writing modes they still make sense.

The shorthands are genuinely more concise too, since padding-block: 1rem 2rem replaces two declarations.

Testing what you actually built

The habit that needs updating alongside the technique: resizing the browser window no longer tests your layout.

Once components respond to container width rather than viewport width, dragging the window edge exercises the page shell and leaves the components untouched. A card can look perfect at every viewport width you try and break at a 280px container width, and no amount of window resizing will find it.

So test components in isolation across a range of container widths, which a component workbench makes straightforward and worth automating. Screenshot comparison across several container widths catches the regressions that viewport-based visual testing structurally cannot, and the testing tools piece covers how to wire that in.

Also worth checking explicitly: 320px viewport width, which remains the sensible floor and where overflow surfaces; 200 per cent text zoom, which is a WCAG requirement and where fluid type traps appear; and a runtime width change rather than a page load at a fixed width, since foldables and resizable windows make that a real scenario rather than a hypothetical.

The short version

Reach for intrinsic sizing before breakpoints. clamp() for values that should scale, repeat(auto-fit, minmax(min(Xrem, 100%), 1fr)) for grids that should reflow, flex: 1 1 Xrem for rows that should wrap.

Use container queries when a component genuinely needs to know its own width, which is more often than viewport queries ever managed to express.

Keep media queries for capability and preference, which is what they were always best at.

Use logical properties by default, reserve space for media, and mix rem into fluid type so zoom still works.

Then test at container widths rather than window widths, because that is where the bugs now live.

The result is usually far less CSS than the breakpoint-driven equivalent, and layouts that behave sensibly at widths nobody thought to test.