Breakpoint-Free Responsive Rows With flex-wrap and flex-basis
A row of three feature boxes has to become a stack on a narrow card, and the same component also appears in a wide sidebar, a modal and a full-width band. Writing a viewport media query for it is wrong on every placement except one, and even a container query needs a width chosen by hand. The narrower problem this page solves is getting a row to decide for itself when to break — using nothing but flex-wrap, a well-chosen flex-basis, and, when every item must flip together, a single threshold expression. It is one of the patterns collected in Flexbox Layout Patterns, part of the wider Mastering Container Queries & Responsive Layouts guide.
Why let the row decide
The browser already knows everything needed to decide whether three boxes fit on one line: the container's width, each item's preferred size, and the gap between them. A breakpoint duplicates that knowledge in a hard-coded number, and the number goes stale the moment the copy gets longer, the gap changes, or the component moves somewhere narrower. Flex line breaking is evaluated on every layout pass against the real available width, so it can never be stale.
There are costs, and they are worth naming. Wrapping is a layout decision only: it cannot change a heading's size or swap an image's aspect ratio when the row becomes a stack. For those you still want container query syntax or a fluid value. And a naïve wrapping row produces an uneven last line, which is the single most common complaint about this technique. Both issues have fixes below.
Accessibility is mostly on the side of wrapping. Content that reflows rather than scrolling horizontally is what WCAG 1.4.10 Reflow asks for at 320 CSS pixels, and a row that decides from its own width keeps working when a reader zooms to 400%, which a viewport breakpoint only approximates. The one thing to guard is reading order: flex-wrap never changes DOM order, so as long as you avoid order and row-reverse, keyboard and screen-reader order match the visual order at every width.
How a flex container breaks a line
With flex-wrap: wrap, the algorithm collects items into a line one at a time. For each item it takes the hypothetical main size — the flex-basis, clamped by min-width and max-width — adds it to the running total plus the gap, and starts a new line as soon as the next item would overflow. Only after the lines are fixed does flex-grow distribute each line's leftover space among the items on that line.
That two-step order explains both the strength and the quirk. The strength: no number in your CSS says "three columns", yet three appear whenever three fit. The quirk: flex-grow has no idea other lines exist, so a final line with two items spreads them across the full width while the lines above hold three narrower items.
The complete implementation
The block below contains three rows. The first is the plain wrapping row. The second fixes the last-line stretch with a max-width. The third uses a threshold so every item flips at the same moment. Drag the demo narrower and watch the three behave differently.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Breakpoint-free rows</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; }
.row {
display: flex;
flex-wrap: wrap; /* allow items onto new lines */
gap: 0.75rem; /* counted into every line-break decision */
margin-block-end: 1.5rem;
}
.row > * {
padding: 0.75rem;
border: 1px solid #94a3b8;
border-radius: 8px;
}
/* 1. Plain wrapping row.
flex: 1 1 11rem = grow, shrink, prefer 11rem. An item drops to the
next line when 11rem (plus the gap) no longer fits beside the others. */
.row--plain > * { flex: 1 1 11rem; }
/* 2. Same row, last line tamed.
max-width stops growth at roughly one column's worth, so a lonely
final item does not stretch to the full container width. The 100%
inside min() keeps a single-column stack full-width on narrow rows. */
.row--capped > * {
flex: 1 1 11rem;
max-width: min(100%, 16rem);
}
/* 3. All-or-nothing switch at a 34rem container width.
100% resolves against the flex container. Above 34rem the basis is a
huge negative number, which clamps up to min-width: 0 and the items
share one line; below it the basis is huge and positive, so every
item takes a full line. There is no in-between state. */
.row--switch > * {
flex-grow: 1;
flex-basis: calc((34rem - 100%) * 999);
min-width: 0; /* let the negative branch actually shrink */
}
</style>
</head>
<body>
<div class="row row--plain">
<div>Plain one</div><div>Plain two</div><div>Plain three</div>
<div>Plain four</div><div>Plain five</div>
</div>
<div class="row row--capped">
<div>Capped one</div><div>Capped two</div><div>Capped three</div>
<div>Capped four</div><div>Capped five</div>
</div>
<div class="row row--switch">
<div>Switch one</div><div>Switch two</div><div>Switch three</div>
</div>
</body>
</html>
Three details are worth reading twice. The gap participates in the line-break arithmetic, so a larger gap makes items wrap earlier — you never need a margin hack that ends up miscounted. The min(100%, 16rem) cap is the only thing keeping a single-item line full-width on a narrow container; without the 100% a capped item on a 12rem card would be narrower than its card allows. And min-width: 0 in the switch row matters because a negative basis is otherwise floored by the item's automatic minimum, a mechanism explained in depth in Preventing Flex and Grid Overflow.
The key technique: a basis that is either huge or negative
The switch row is the part people copy without understanding, so it is worth taking apart. flex-basis accepts a percentage, and in a row flex container that percentage resolves against the container's inner width. calc((34rem - 100%) * 999) therefore depends on one thing only: whether the container is narrower or wider than 34rem.
Below the threshold, 34rem - 100% is a small positive length, and multiplying it by 999 makes it far wider than any container. Every item's hypothetical size exceeds the line, so each one starts its own line and then grows to fill it: a stack. Above the threshold, the subtraction goes negative, the product is a huge negative length, and a negative size clamps up to the item's minimum — which min-width: 0 sets to zero. Every item now claims nothing, they all fit on one line, and flex-grow: 1 shares the width equally.
The multiplication is only there to exaggerate the slope. Without it, a container one pixel under the threshold would give each item a one-pixel basis and nothing would wrap. With it, the transition from "all on one line" to "all stacked" happens across roughly a thirtieth of a pixel, which is effectively instantaneous.
Why not simply write a container query? You can, and on a component that already establishes a container it is clearer. The threshold trick earns its place when the row lives inside a parent you do not control: it needs no container-type on an ancestor, so it cannot trigger the zero-width collapse described in container-type: size vs inline-size, and it keeps working inside a shadow root or third-party wrapper.
Choosing between the three rows
The plain row is the right default for tags, buttons and chips, where items have different natural widths and an uneven last line reads as natural. The capped row suits cards, where one oversized final card looks like a bug. The threshold switch suits groups that must never be half-wrapped — a price, a label and a button that only make sense all in a row or all stacked. When every column must be identical width regardless of how many fit, stop fighting flexbox and reach for auto-fit and minmax() grids, where tracks are shared by every row.
Variation: vertical-only rhythm and RTL
Two small extensions make the pattern production-ready. First, gap accepts separate row and column values, and wrapped rows usually want more space between lines than between items so the eye reads each line as a group: gap: 1.25rem 0.75rem. Second, flex rows follow the writing mode automatically. Set dir="rtl" on the container and the first item appears on the right, lines still break in the correct order, and the threshold expression needs no change because 100% is a width, not a direction.
/* Wider line spacing than item spacing, logical padding,
and a subtle divider that only appears between items on the same line. */
.row--meta {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 0.75rem; /* row-gap column-gap */
padding-inline: 1rem; /* flips correctly in RTL */
}
.row--meta > * {
flex: 1 1 9rem;
padding-inline-start: 0.75rem;
border-inline-start: 2px solid #94a3b8;
}
/* Keyboard users see the same order as everyone else: never reorder. */
.row--meta > :focus-visible {
outline: 2px solid #2d5bff;
outline-offset: 2px;
}
Do not be tempted to use flex-wrap: wrap-reverse or order to "fix" a stacking order that looks wrong on mobile. Both move items visually while leaving the DOM untouched, so tab order and reading order go one way and the eye goes the other — a WCAG 2.4.3 Focus Order problem. If the order must change, change the source.
Browser support
flex-wrap, flex-basis and percentage bases are supported in every browser still in use, so the plain and capped rows need no guard. The gap property for flex containers is also universally available in current engines; only browsers several years out of date lack it, and there the items simply touch. The min() function in the capped row is supported from Chrome and Edge 79, Firefox 75 and Safari 13.1, and calc() in flex-basis is older still, so the threshold switch works everywhere the rest of your modern CSS does. No @supports guard is required for anything on this page.
FAQ
Why does the last item on a wrapped flex row stretch across the whole line?
Each flex line distributes its own free space. When the final line holds fewer items than the others, flex-grow hands that line's leftover width to the few items on it, so they grow wider than their siblings above. Either cap growth with a max-width, drop flex-grow, or switch to grid with auto-fill when equal columns matter.
Is flex-wrap a replacement for container queries?
For deciding when items drop onto a new line, often yes, because the decision is made from the container's width with no query at all. It cannot change anything other than line breaking, so typography, spacing and internal component layout still need a container query.
How does the calc((threshold - 100%) * 999) trick work?
The percentage resolves against the flex container's width. Above the threshold the result is a large negative basis, which clamps to the min-width so items sit side by side; below it the result is a huge positive basis, forcing every item onto its own line. All items flip at the same container width.
Why do my items wrap one at a time instead of all together?
Because an ordinary flex-basis is compared with the free space item by item, so the line breaks as soon as the next item no longer fits. To make the whole row switch at once, give every item the same computed basis derived from the container width, as the threshold technique does.
Related
- Flexbox Layout Patterns — the parent guide to flex-based layout decisions.
- flex-basis vs width — which size a flex item actually starts from.
- Flexbox gap and Spacing — how gap feeds the line-break arithmetic.
- auto-fit and minmax() Grids — equal columns when an uneven last row is unacceptable.
- Container-Query-Triggered Keyframe Animations — animating the moment a component changes layout.
Related articles
More pages in the same section.