Flexbox gap vs Margins: Spacing Items Without Edge Hacks
Every flex row of buttons, tags or cards needs space between its items and none outside them. For years that meant a margin on every item plus a :last-child reset, and when the row wrapped the reset stopped working because the last item on each line was not the last child. The narrow question this page answers is how to space flex items so the spacing is correct at every width and in every direction, and what margins are still for once gap handles the common case. It is part of Flexbox Layout Patterns within the Mastering Container Queries & Responsive Layouts guide.
Why margins fail in a wrapping row
Margins belong to items. The item has no idea which line it landed on or whether it is the last on that line, so any rule that tries to suppress the trailing margin has to guess from the DOM. In a single-line row the guess is right: :last-child really is the end of the line. In a wrapped row it is wrong on every line except the final one, and each line gains a trailing gutter that pushes it off-centre or breaks right alignment.
The traditional workaround was a negative margin on the container equal to the item margin, which cancels the outer gutter at the cost of overflowing the container by that amount. It works until the container has a background, a border, or overflow: hidden, all of which reveal the trick. gap removes the need for any of it because it belongs to the container, and the container is the one component in the system that knows where the lines break.
The complete implementation
The toolbar below uses every spacing tool a flex row needs: gap for regular spacing, an auto margin for the one item that should sit at the far end, and a larger row gap for wrapped lines.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Flex gap toolbar</title>
<style>
body { font: 15px/1.4 system-ui, sans-serif; margin: 1rem; }
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
/* row-gap column-gap: wrapped lines get more air than neighbours,
so each line reads as its own group. */
gap: 0.75rem 0.5rem;
padding: 0.75rem;
border: 1px solid #94a3b8;
border-radius: 10px;
}
.toolbar button {
padding: 0.45rem 0.8rem;
border: 1px solid #94a3b8;
border-radius: 6px;
background: transparent;
font: inherit;
}
/* The auto margin absorbs ALL remaining free space on the line,
pushing this item and everything after it to the far end.
Logical property, so it flips correctly in right-to-left text. */
.toolbar .push {
margin-inline-start: auto;
}
/* A divider between groups without an extra wrapper: a pseudo-element
would be counted as a flex item, so draw it with a border instead. */
.toolbar .group-start {
padding-inline-start: 0.75rem;
border-inline-start: 1px solid #94a3b8;
align-self: stretch; /* an empty span is 0px tall under align-items: center */
}
.toolbar button:focus-visible {
outline: 2px solid #2d5bff;
outline-offset: 2px;
}
</style>
</head>
<body>
<div class="toolbar" role="toolbar" aria-label="Formatting">
<button type="button">Bold</button>
<button type="button">Italic</button>
<button type="button">Link</button>
<span class="group-start"></span>
<button type="button">Bullet list</button>
<button type="button">Numbered list</button>
<button type="button" class="push">Publish</button>
</div>
</body>
</html>
Two decisions in that block are easy to miss. The group divider is a real element with a border rather than a ::before on the button, because generated content on a flex container becomes a flex item and would receive a gap on both sides. And the auto margin sits on the item, not the container: auto margins resolve after gaps are placed, so the Publish button still keeps its gap from its neighbour and then takes every remaining pixel on its inline-start side.
The key technique: gap participates in line breaking
The reason gap is more than a convenience is that the flex algorithm counts it when deciding where lines break. The hypothetical size of each line is the sum of its items' sizes plus the gaps between them, and a new line starts as soon as that sum would exceed the container. Nothing is subtracted after the fact.
With margins, the same arithmetic counts the trailing margin of the last item on each line, so items wrap one gutter earlier than they should and the visible spacing never quite matches the numbers in the stylesheet. With gap, what you write is what the line-break sum uses, which makes wrap points predictable enough to reason about — the threshold behaviour described in Breakpoint-Free Rows With flex-wrap relies on exactly this.
gap also never collapses. Vertical margins between block siblings collapse to the larger of the two, but flex items do not collapse margins at all, and gaps are not margins, so a row-gap of 1rem is always 1rem. The mental model is simpler than block flow, which is one reason many teams now make every stack a flex or grid container purely to use gap.
When margins are still the right tool
gap is uniform: every pair of neighbours gets the same space. Real layouts often want exceptions, and those are still margin territory. The diagram below sorts the common cases.
Remember that margins and gaps add. An item with margin-block-start: 1rem in a column container with gap: 1rem sits 2rem below its neighbour. That additive behaviour is useful — a heading inside a stack can take margin-block-start: 1rem to get "the usual gap plus a bit more" without redefining the stack — but it surprises anyone expecting margins to collapse into the gap the way they collapse into each other in block flow.
Variation: a fluid gap that scales with the container
A fixed gap looks cramped on a wide dashboard and wasteful on a narrow card. Because gap accepts any length, it can use container query units inside clamp(), so spacing scales with the component rather than the viewport.
.card-row-wrapper {
container-type: inline-size;
}
.card-row {
display: flex;
flex-wrap: wrap;
/* 0.5rem on tiny containers, 1.5rem on wide ones, 3% of the
container's inline size in between. The rem floor and ceiling
keep the gap sensible under text zoom. */
gap: clamp(0.5rem, 3cqi, 1.5rem);
}
.card-row > * {
flex: 1 1 14rem;
}
Because the gap is part of the line-break sum, a fluid gap also shifts the wrap points slightly as the container grows. That is usually desirable — the row keeps its proportions — but it is worth knowing when a test asserts an exact wrap width. For a whole spacing system built this way, Fluid Space Scale With clamp() builds matched steps rather than one-off values. The same custom-property approach can drive motion as well: fluid spacing tokens driving transition durations ties a component's timing to the same scale.
Migrating an existing margin-based system
Most codebases do not start fresh. They have a utility such as .stack > * + * { margin-block-start: 1rem } — the "lobotomized owl" selector — or a .row > * { margin-right: 1rem } with a last-child reset scattered across components. Replacing them all at once is risky because some components depend on the margin for reasons nobody documented, such as a card that relies on its own bottom margin when it is used outside the stack.
A safe migration runs in three passes. First, add display: flex; flex-direction: column; gap: var(--stack-space) to the stack utility alongside the owl rule, and set the owl margin to the same custom property. For one release, both exist, and because flex items do not collapse margins, every gap is temporarily doubled — which is visible, testable, and easy to find in a screenshot diff. Second, delete the owl rule. The doubling disappears and the spacing returns to the intended value, now produced by the container. Third, search for :last-child and :first-child margin resets and delete those that only existed to cancel the old gutter.
Two things change behaviour during the switch and should be checked on real pages. Hidden children — elements with display: none — never received the owl margin and never receive a gap, so they are safe, but elements hidden with visibility: hidden or zero opacity still occupy a flex slot and still get gaps on both sides. And absolutely positioned children are taken out of flow, so they are skipped by gap exactly as they were by the owl selector's adjacency, but a position: sticky child is still in flow and still gets spaced. Neither is a bug, but both can move a pixel-perfect layout by a gap's width.
Browser support
gap for flex containers is supported in every current engine; it shipped years after grid gap, so very old browsers that support flexbox but not flex gap render items touching, which is a cosmetic failure rather than a broken layout. Logical margin properties such as margin-inline-start are equally universal in current browsers. The container query units in the variation need Chrome and Edge 105+, Firefox 110+ or Safari 16+, and clamp() needs Chrome and Edge 79+, Firefox 75+ or Safari 13.1+. A plain gap: 1rem before the fluid declaration covers anything older.
FAQ
Does gap add space before the first item or after the last one?
No. gap only inserts space between adjacent items and between adjacent lines, never along the container's edges. That is the main reason to prefer it over margins, which need a first-child or last-child exception to avoid an outer gutter.
Can gap take a percentage value in a flex container?
Yes, it resolves against the container's content box in the relevant axis. In a row container a percentage column-gap is a share of the width. A percentage row-gap needs a definite container height, otherwise it behaves as zero, so use a length or a fluid clamp() value for rows.
Are auto margins still useful now that gap exists?
Yes, for a completely different job. gap sets fixed spacing between every pair of items, while margin-inline-start: auto absorbs all free space on one side of a single item, which is how you push a logout button to the far end of a toolbar.
Why does my row wrap earlier after I increased gap?
Because gap is included in the line-break calculation. Each item plus the gaps before it must fit in the line, so a larger gap leaves less room for items and the line breaks sooner. This is correct behaviour and one of the advantages over margins.
Related
- Flexbox Layout Patterns — the parent guide for flex-based components.
- flex-basis vs width — the item sizes that the gap is added to.
- Centering in CSS: Every Method — auto margins used for alignment rather than spacing.
- Fluid Space Scale With clamp() — a whole spacing system for gaps.
- Staggered List Animations With Custom Properties — animating the items a gap separates.
Related articles
More pages in the same section.