Okay, this is going to sound dumb, but I lost about three hours last month trying to align a card grid: titles, meta rows, buttons, all sitting at the same vertical position across cards of different lengths. I was about halfway into writing JavaScript to measure the tallest title and set a min-height, when a colleague pinged me in Slack: “you know CSS Subgrid exists, right?” Reader, I did not. Well, I knew the word. I’d read a blog post about it in 2023 and filed it under “maybe useful someday”. It turns out someday was that afternoon.
This post is the version of that blog post I wish I’d read: no history lesson, no browser support timeline you can look up on Can I Use in five seconds, just the pattern I now reach for and the two footguns I hit while learning it.
What subgrid actually solves
Here’s the problem. You have a card grid. Each card has a title, a body, and a footer with a button. When the titles have different lengths, the bodies start at different vertical positions. When the bodies have different lengths, the buttons end up on different rows. Users notice. Designers definitely notice.
Before subgrid, my options were:
- Make every title the same height with
line-clampand pray about long titles. - Set a
min-heighton each section, which meant either a lot of white space on short content or clipping on long content. - JavaScript. Measure the tallest of each row, apply a fixed height. Reruns on resize. This is what I was about to write.
Subgrid lets a child element opt into its parent’s grid tracks, so the title row, body row, and footer row across all cards line up automatically. No measurement, no JavaScript, no clipping. That’s the whole idea.
The grid-template-rows: subgrid line that changed my week
Here’s the shape. The outer grid defines the columns and, crucially, the row structure. Each card spans three rows and inherits those row tracks with grid-template-rows: subgrid.
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-rows: repeat(3, auto);
gap: 1rem;
}
.card {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid;
}
.card > .title { grid-row: 1; }
.card > .body { grid-row: 2; }
.card > .footer { grid-row: 3; }
The magic word is subgrid on grid-template-rows. The child card is now a participant in the outer grid’s row layout. The three rows are sized by the tallest title, the tallest body, and the tallest footer across the whole grid. Every card’s title, body, and footer end up on the same baseline. It looks like you spent a long afternoon fine-tuning heights. You didn’t.
If you also want the columns to align across nested content (a metadata row inside the card where the icon should line up with the icons in every other card, for example), you can add grid-template-columns: subgrid too, though it’s rarer than the row case.
Before and after, in a real project
Here’s what my card grid looked like before I stopped pretending subgrid didn’t exist:
<div class="card-grid">
<article class="card">
<h3 class="title">Short title</h3>
<p class="body">A body of a few words.</p>
<button class="footer">Read more</button>
</article>
<article class="card">
<h3 class="title">A slightly longer title that wraps to two lines</h3>
<p class="body">A body that goes on for a bit longer than the first one, wrapping across a couple of lines and creating a taller card.</p>
<button class="footer">Read more</button>
</article>
</div>
/* Before: cards are flex columns, buttons end up at different heights */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.card .footer { margin-top: auto; } /* the classic "push me down" hack */
The margin-top: auto trick pushes the button to the bottom of each individual card, so buttons align on a per-card basis but not across the grid. Cards with shorter content have a big gap in the middle. Not great.
With subgrid, the same HTML becomes:
/* After: real cross-card alignment */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-rows: repeat(3, auto);
gap: 1rem 1rem;
}
.card {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--hairline);
border-radius: 0.5rem;
}
The titles, bodies, and footers now share three global rows sized by the tallest instance of each. Buttons line up across the grid. Zero JavaScript. This is what I would have shipped six months ago if I’d remembered subgrid was a real thing I could type.
Forms are the sneaky killer app
Card grids are the obvious pitch, but the place I’ve used subgrid the most since is forms. If you’ve ever tried to make a form where labels are in one column and inputs are in another, and the whole thing has to look tidy across sections and repeats, you know the pain.
form {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5rem 1rem;
}
.field {
display: grid;
grid-column: span 2;
grid-template-columns: subgrid;
align-items: baseline;
}
.field label { grid-column: 1; }
.field input, .field select { grid-column: 2; }
The field wrapper participates in the parent grid’s two columns. Labels line up on the left, inputs on the right, and it doesn’t matter how you group fields with <fieldset> or <div class="field">. Every label sits under every other label. It’s the layout I used to build with a bunch of manual widths and text-align: right, and it’s the layout that would fall apart the moment a translator turned a two-word label into a five-word label. Subgrid makes it robust.
What still breaks and how I work around it
Three things bit me in the first week:
- You have to remember the
span. If your card isgrid-row: subgridwithout also spanning multiple rows, it takes only one row and the whole thing collapses. Always pairgrid-template-rows: subgridwith an explicitgrid-row: span N. gapon the child grid is ignored. The gap comes from the parent. This one confused me for a while. I was settinggap: 1reminside the card and getting nothing. Move it to the parent grid.- Nested subgrids get weird fast. Don’t nest a subgrid inside a subgrid inside a subgrid unless you have a very good reason. Two levels is usually the sweet spot. Beyond that, debugging becomes archaeology, and you’re better off restructuring the layout.
Browser support isn’t the problem it once was. All the modern engines have shipped subgrid: Firefox got there first, Safari followed, and Chrome finally landed it in 117. The MDN page on subgrid has the exact matrix, and the web.dev article on subgrid is the best conceptual explainer I’ve found. If you’re supporting a browser matrix that includes something older than Chrome 117, you’ll want a @supports fallback that uses flexbox and a min-height; annoying, but not catastrophic.
What I’d try next weekend
If you’ve been shipping card layouts with min-height hacks or measuring things in JavaScript, this weekend try one thing: pick a component in your app, open the CSS, and see if the layout is really “cards in a grid where corresponding rows should align”. If it is, delete the min-height and add grid-template-rows: subgrid on the child with an explicit span. If it looks right, ship it. If it looks wrong, the debugging story is much shorter than you’d expect because subgrid is easy to inspect in DevTools; Firefox in particular has a grid inspector that highlights the tracks.
I wrote about a similar “stop writing JavaScript for a CSS problem” pattern in how the :has() selector let me delete a pile of JavaScript hacks. Subgrid is the same kind of win, applied to layout rather than state. You can see it working on a real card grid on my portfolio at abrarqasim.com; the featured work section uses exactly the pattern in this post.
One concrete action for the week: go find the tallest min-height value in your codebase and check whether the element it’s attached to lives inside a grid. If it does, you probably have a subgrid opportunity, and about ten lines of CSS will replace whatever it’s currently doing.