Skip to content
Chapter 20Lesson 6

Spacing flex and grid items with gap

Why Tailwind's gap utility is the default for spacing items inside flex and grid layouts, leaving margin a narrow role.

You have a vertical stack of cards, or a row of buttons, and you need even space between them. It is the most ordinary layout task there is, and the answer has changed twice in seven years. The old trick put margin-bottom on every card except the last one, which cost you a :last-child rule or per-item bookkeeping every time. Tailwind’s space-y-* automated that bookkeeping but kept the same underlying trick. You now do neither. You put flex flex-col (or grid) on the parent and add one gap-*: the parent declares the layout, gap does the spacing, and no child carries a spacing rule of its own.

<ul className="flex flex-col">
{invoices.map((invoice) => (
<li
key={invoice.id}
className="mb-4 last:mb-0 rounded-lg border p-4"
>
{invoice.title}
</li>
))}
</ul>
<ul className="flex flex-col gap-4">
{invoices.map((invoice) => (
<li key={invoice.id} className="rounded-lg border p-4">
{invoice.title}
</li>
))}
</ul>

The two snippets show the whole shift: the spacing rule moves off every child and onto the parent, once. The flexbox and grid lessons already used gap-* as the assumed default; this lesson justifies it. By the end you will reach for gap inside any flex or grid container, know the one narrow case where margin still earns its place, and recognize the legacy patterns when an old codebase or an AI hands them to you.

This builds on the box model from The box model: gap slots between the padding and margin you already know, and narrows margin’s job down to almost nothing.

Padding, gap, and margin: one spatial model

Section titled “Padding, gap, and margin: one spatial model”

The three spacing tools are not interchangeable. Each answers the same question, “where does this space sit relative to the element?”, with a different answer.

  • Padding is space inside the element, between its border and its content.
  • Gap is space between sibling items inside a flex or grid container. It belongs to the parent, not the children, and sits only between items, never before the first or after the last.
  • Margin is space outside the element, pushing it away from neighbors that are not its flex or grid siblings.
a box outside sibling sibling padding inside the parent gap between siblings margin outside · pushes away
Padding sits inside the element, gap sits between siblings, margin sits outside — pushing away from things in a different container.

In 2026 you write padding and gap constantly and margin almost never. Padding handles every “space inside,” gap handles every “space between siblings,” and together they cover the overwhelming majority of layouts. Margin is left with one narrow job, pushing an element away from something outside its container, plus the mx-auto centering case you already met. If you catch yourself putting margin between siblings in a flex or grid container, take it as a sign something is off: delete the margin and put gap on the parent.

gap is a single declaration you set on a flex, grid, or multi-column parent, and it spaces every child at once. Tailwind’s gap-* compiles straight to it: gap-4 becomes gap: calc(var(--spacing) * 4), on the same --spacing scale as every p-* and m-*. Because they share that one variable, the space between elements stays in lockstep with the space inside them, and you tune both from @theme.

The behavior that makes gap the right tool is that it adds space between items and nowhere else: no gap before the first child, none after the last. Edge spacing isn’t its job; the parent’s own padding owns that. So the canonical container is two utilities working as a pair:

<div className="flex flex-col gap-3 p-4">
<Row />
<Row />
<Row />
</div>

p-4 insets every edge uniformly, gap-3 opens an even channel between the rows, and no child carries a spacing rule. That last point is what makes gap robust: the spacing is computed from the layout, not pinned to individual children.

You see that robustness when the item count changes or items wrap. Add a fifth child to a gap-3 column and it inherits the spacing for free, with no :last-child exception and no per-item margin to add. Let a row wrap to a second line and gap spaces both axes at once: the horizontal channel between items in a row and the vertical channel between the rows. Watch that wrapping case, since it’s where the legacy approach first breaks down in the next section.

When you want different spacing per axis, gap splits in two: gap-x-* sets the horizontal channel and gap-y-* the vertical, useful in wrapping layouts where rows should sit closer than the items within them. The plain gap-* is just shorthand for both set to the same value.

The playground below is a flex container of chips with a gap slider and a row/column toggle. Drag the gap and every channel moves together; flip the direction and the same property spaces a column as cleanly as a row.

One gap value, every channel. Drag it and all the spacing moves together; flip the direction and the same property spaces a column as cleanly as a row, with no per-child edits.

One stale worry to put down: older tutorials warned that gap “doesn’t work in flexbox.” That stopped being true in 2021. gap on flex containers ships in every major browser at high-nineties global support, so you can use it with no fallback and no caveat.

Every legacy spacing approach shares one mechanism, and all four failures below are symptoms of it, so name the mechanism first.

Tailwind’s space-x-* and space-y-* utilities compile to a selector that puts a margin on every child except the last. space-y-4, for example, gives each child but the final one a margin-bottom. The spacing is computed per child, by DOM position: am I the last child or not? That is the seed of every break, because the spacing now depends on which child happens to be structurally last and on the source order of the children. gap is one property on the parent, with no per-child selector and no position arithmetic. Each failure below falls out of that difference.

First, wrap breakage. space-x-* puts a horizontal margin on each item but the last, and a margin knows nothing about where rows break. Let the items wrap to a second line and the spacing falls apart: rows don’t line up, and there is no vertical spacing between them at all, because the trick only ever added horizontal margins. gap handles both axes the instant items wrap, an even horizontal channel and an even vertical one, with no extra code.

<div className="flex flex-wrap space-x-2">
{tags.map((tag) => (
<span key={tag} className="rounded-full border px-3 py-1">
{tag}
</span>
))}
</div>

Breaks on wrap. The horizontal margin sits on each tag but the last; when the tags wrap to a second row they misalign, and there’s no spacing between the rows at all.

Second, hidden and reordered children. This is the failure that matters most, because it is where the per-child mechanism becomes a real bug in a real component. The space-y-* margins live on every child but the last. Hide the last child with the hidden class (it sets display: none, the way you toggle a row off without unmounting it). That child keeps its place as the structurally-last child, so the selector still skips it, and the row that is now last on screen still carries a bottom margin meant to sit between rows. A phantom gap hangs below the visible list, spacing against nothing. Reorder children with order-* and you get the mirror image: the skipped child is no longer the one that sits last on screen, so the margins land in the wrong places. gap avoids both, because it spaces only the items actually laid out. A display: none child is pulled out of layout entirely, so no space is reserved for it and there is no last child to get wrong.

<ul className="space-y-4">
<li>Profile</li>
<li>Billing</li>
<li className="hidden">Team settings</li>
</ul>

Phantom trailing gap. The hidden row stays in the DOM as the structural last child, so the now-visually-last Billing row keeps the bottom margin meant to sit between rows. A gap hangs below the list, spacing against a row no one can see.

Reading about a phantom gap and seeing one are different things, so trigger it yourself. The starter below spaces a settings list with space-y-4 and hides its last item with hidden. Swap space-y-4 for flex flex-col gap-4 on the parent and watch the stray gap vanish. Match the target on the right.

The starter spaces this list with space-y-4. The last item is hidden with the hidden class, so it stays in the DOM as the structural last child — leaving a stray gap dangling below the visible rows. Convert the parent to flex flex-col gap-4 so the spacing comes from the layout instead of a per-child margin. The phantom gap disappears. Match the target.

Target
Your output LIVE

Third, margin collapse. space-y-* uses real margins, and real margins collapse: as you saw in the box-model lesson, two adjacent vertical margins merge into the larger of the two instead of adding. So when a child carries its own vertical margin, the space-y margin can collapse against it and quietly under-space the list, with nothing in the markup to explain why you asked for 16px and got 12px. gap is not a margin and never collapses, so the spacing you ask for is the spacing you get.

Fourth, right-to-left layouts. A physical margin like margin-right is pinned to the right edge. It doesn’t flip when the document switches to right-to-left even though the layout around it does, so the spacing lands on the wrong side. gap spaces along the layout’s axis whichever way that axis runs, so an RTL flip just works, the same logical-versus-physical idea behind ps-* and pe-* in the box-model lesson.

So here is the rule. space-x-* and space-y-* survive only for the rare parent you genuinely can’t turn into a flex or grid container without side effects, and that parent is rarer than it sounds, because the clean fix is almost always flex flex-col (or flex) plus one gap. The * + * “lobotomized owl” selector you may spot in hand-written CSS, and space-y itself, are recognition-only: dead for new code, alive in legacy and in AI output trained on it. When you see them, you now know both why they’re there and what to replace them with.

gap adds invisible space between items. Sometimes you want a visible line instead, like the hairline rules between rows in a settings panel. gap can’t draw one; divide-* can.

divide-y-* and divide-x-* put a border between direct children. divide-y adds a border-bottom to every child except the last, so the rules land between the items rather than wrapping the container. Add divide-color-* to tint the lines, and the whole thing composes with the parent’s own border and rounded-* to make the classic bordered list card: one rounded, bordered container with hairlines separating its rows.

<ul className="divide-y divide-slate-200 rounded-lg border border-slate-200">
<li className="px-4 py-3">Account</li>
<li className="px-4 py-3">Notifications</li>
<li className="px-4 py-3">Billing</li>
</ul>

There’s no gap here: the rows sit flush, separated only by a hairline. But divide and gap aren’t rivals, they compose. divide draws a line, gap adds space, and many designs want both: items held apart and a rule between them. Reach for divide when the separation should be visible, gap when it’s just breathing room, and both when you want both.

Margin’s surviving role is narrow enough to state as a mechanical rule:

  • Use gap between siblings inside a flex or grid container. This is roughly ninety percent of the spacing you’ll write.
  • Use margin to push an element away from something outside its flex or grid container, or where there’s no flex or grid parent to own a gap. The honest cases are rare: mx-auto to center a lone block, and pushing one element away from a non-sibling neighbor.
  • Never use margin between siblings inside a flex or grid container. That’s gap’s job, and a sibling margin there only invites the collapse and RTL bugs you just saw.

The shift in mindset: padding and gap cover almost everything, and margin is the exception, not a co-equal third tool. Newcomers reach for margin first, because it’s the spacing property they learned first; experienced developers reach for gap first and treat a sibling margin as something that needs explaining.

Drill the decision on fresh cases. Sort each spacing situation into the tool you’d reach for.

Sort each spacing situation into the tool you'd reach for first in a 2026 codebase. Drag each item into the bucket it belongs to, then press Check.

Padding Space inside an element
Gap Space between flex/grid siblings
Margin Push away from something outside the container
Even space between cards in a flex flex-col list
Space between an avatar and a name in a flex row
Space between buttons in a toolbar flex row
Center a max-w-3xl article in the viewport with mx-auto
Push a “Danger zone” block away from the form section above it
Space inside a button between its border and its label
Inset every edge of a card from the content inside it

When you want to go deeper into the Tailwind utility you write, the CSS property behind it, or an interactive feel for where gap lives, these are worth a bookmark.