Every card in the row has a headline, a paragraph, and a button. One headline wraps to two lines and the others don’t. Now that card’s paragraph starts a line lower than its neighbours’, its button sits lower than theirs, and a row that should read as one object reads as three objects placed by hand.
So you reach for the fix you know. display: grid on the card,
grid-template-rows: auto 1fr auto, and the buttons snap to the bottom edge.
Then you look again. The buttons line up. Nothing else does.
That’s the boundary the beginner model leaves out. Tracks belong to the container that declared them, and they stop at that container’s edge. Every card is its own coordinate system, and coordinate systems don’t negotiate.
Subgrid is how you make them negotiate. It’s a smaller feature than its reputation suggests, and it’s mostly one keyword and one number.
Where Cards Stop Lining Up
Here’s the layout. A wrapping card grid, and each card is a three-row grid of its own:
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1.5rem;
}
.card {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 0.75rem;
}
This looks like it should work, and it half does. Grid items stretch to fill
their cell by default, so all three cards are the same height. The 1fr
middle row absorbs the difference, so each card’s third row sits flush against
its own bottom edge. Three bottom edges, all at the same y. Three buttons, all
in line.
The headline row is the problem. It’s auto, so it’s sized by the content of
that card, and each card computes it separately. One card’s headline row is
two lines tall, the other two are one line tall, and everything below inherits
the offset. Call it the Local Rows Problem: three grids doing the right
thing three times, with no mechanism to compare answers.
Nothing you can write inside .card fixes this, because the card has no idea
its siblings exist. The information you need — the tallest headline in the row
— only exists one level up, in a container that has no rows to put it in.
A Subgrid Stands On Its Parent’s Tracks
grid-template-rows: subgrid
tells a grid item to stop defining its own tracks in that axis and use its parent’s instead. Not a copy of them. The same tracks, for exactly the region the item spans.
Which means the card’s children are no longer laid out against the card. They are placed into the outer grid’s rows, and they participate in sizing those rows. Row one is as tall as the tallest headline across every card sitting on it, because every one of those headlines is contributing to the same track.
The sizing runs both directions, and that’s the part worth holding onto. A subgrid isn’t a passive reader of its parent’s measurements — its own contents push those measurements around, the same way any grid item would. Take the loan out and you’re also on the hook for it.
Fig. 02 — One set of rows, two cards. The headline row is as tall as the taller headline, and the short one leaves a gap.
Notice what that costs. The card with the one-line headline now has empty space under it, because its headline row is sized by somebody else’s copy. That’s the trade, and it’s the right one nearly every time — a row of cards reads as a unit when the eye can follow a straight line across it, and a little air under a short headline is invisible by comparison.
A subgrid doesn’t copy its parent’s tracks. It stands on them.
— Layout_Doctrine // §4
Subgrid is per-axis, which people miss. grid-template-rows: subgrid and
grid-template-columns: subgrid are independent, and a card that subgrids its
rows can still declare whatever columns it likes internally. Most real uses
subgrid one axis and leave the other alone.
The Span Is The Contract
The card version is shorter than the one it replaces:
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1.5rem;
}
.card {
grid-row: span 3;
display: grid;
grid-template-rows: subgrid;
row-gap: 0.75rem;
}
Three things have to be true, and all three are easy to half-do. The card must
be a grid container itself — grid-template-rows: subgrid on a block element
does nothing at all, in the familiar silent way. It must be a grid item of the
grid it’s borrowing from, so no wrapper div in between. And it must span a
known number of parent rows, because the span is what decides how many
subgridded rows the card gets.
That last one is the only real bookkeeping subgrid adds. span 3 says the
card occupies three of the outer grid’s rows, so it has three rows to place
children into. Add a badge row to the card six months from now and the span
has to grow with it. There’s no error when it doesn’t; the layout goes quietly
wrong, which is the failure mode this whole area specialises in.
!
Support: Firefox shipped subgrid in 2019, Safari in 16, and Chromium in 117 in September 2023. That makes it safe across evergreen browsers, but check your own floor if you still support anything older.
One behaviour surprises people the first time a card grid wraps. The outer
grid places items in order, so with span 3 the first row of cards occupies
rows one to three, the next occupies four to six, and so on. Alignment happens
within a band, not across the whole page. Cards in the second row line up
with each other and not with the row above.
That’s almost always what you want. The alternative — one tall headline anywhere on the page inflating the headline row for every card — would be a worse layout and a strange thing to ask for. It’s worth knowing before you go looking for a bug that isn’t there.
There is an older trick that gets the same alignment, and it’s worth naming so you can rule it out deliberately rather than by accident:
Breaks
display: contents on the card promotes its children into the outer grid. They align, and the card’s box stops generating — no border, no background, no padding, nothing to hang a hover state on.
Holds
grid-template-rows: subgrid keeps the card as a real element that still paints and still wraps its own content, while its rows come from the parent.
Line Names Come Along
The other half of subgrid is quieter and, for page layout, more useful: a subgrid inherits the parent’s line names across the region it spans. Which turns the named-column article layout from a thing you set up once into a thing nested content can actually use.
.page {
display: grid;
grid-template-columns:
[full-start] 1fr
[text-start] minmax(0, 68ch)
[text-end] 1fr
[full-end];
}
A section that spans the full width can adopt those three columns, and everything inside it can then be placed by name instead of by arithmetic:
.section {
grid-column: full-start / full-end;
display: grid;
grid-template-columns: subgrid;
}
.section > p {
grid-column: text-start / text-end;
}
.section > figure {
grid-column: full-start / full-end;
}
The minmax(0, 1fr)-style discipline from
the earlier post on track sizing
still applies here — a subgridded track can be blown out by unbreakable
content exactly like any other track, because it is any other track. Nothing
about subgrid changes how a track decides its size; it only changes who gets
to place things on it.
Two mechanics to keep in your head. A subgrid uses its parent’s gutters by
default, and setting gap on the subgrid overrides them for that axis — which
is what row-gap: 0.75rem on the card is doing above, so the space between
rows inside a card doesn’t have to match the space between cards.
And padding is charged to the tracks rather than added around them. Give a subgrid padding on its subgridded axis and the first and last tracks are inset by that amount, because the tracks live in the content box. Cards with identical padding stay aligned with each other, so