Skip to content
Development4 min read

Aligning Card Grids With Subgrid

Three cards, three different lengths of copy, and every footer still lines up. Subgrid does it without a fixed height or a line of JavaScript.

Otter

Platform Native

An ongoing series on techniques that ship with the browser already - no library, no build step required.

Put three cards in a row and give them real content instead of placeholder text, and they stop lining up. One card’s copy runs a line longer than its neighbors, its footer drops a little lower, and the row that looked tidy in the mockup looks slightly off once actual words are in it. The usual fixes are a fixed height that breaks the moment the copy changes again, or reaching for JavaScript to measure each card and force them level.

Subgrid does it properly. Look at the middle card here - its text runs noticeably longer than the other two - and check where all three footers land:

Layout

Subgrid lines cards up to a grid their parent controls, not one each card invents for itself.

Field note
Typography

This card's body just happens to run a couple of lines longer than its neighbors. Real copy rarely arrives in matching lengths, and a card grid has to survive that without anyone touching a height value by hand.

Field note
Motion

Short copy, same footer position as its longer neighbor.

Field note

HTML

<div class="sg-grid">
  <article class="sg-card">
    <span class="sg-eyebrow">Layout</span>
    <p class="sg-body">Subgrid lines cards up to a grid their parent controls, not one each card invents for itself.</p>
    <span class="sg-foot">Field note <span aria-hidden="true">→</span></span>
  </article>
  <article class="sg-card">
    <span class="sg-eyebrow">Typography</span>
    <p class="sg-body">This card's body just happens to run a couple of lines longer than its neighbors. Real copy rarely arrives in matching lengths, and a card grid has to survive that without anyone touching a height value by hand.</p>
    <span class="sg-foot">Field note <span aria-hidden="true">→</span></span>
  </article>
  <article class="sg-card">
    <span class="sg-eyebrow">Motion</span>
    <p class="sg-body">Short copy, same footer position as its longer neighbor.</p>
    <span class="sg-foot">Field note <span aria-hidden="true">→</span></span>
  </article>
</div>

CSS

.sg-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: auto 1fr auto;
  gap: 1px;
  background: var(--color-gray-200);
}
@media (max-width: 640px) {
  .sg-grid {
    grid-template-columns: 1fr;
  }
}
.sg-card {
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
  gap: 0.6rem;
  background: var(--color-white);
  padding: 1.25rem;
}
.sg-eyebrow {
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 0.7rem;
  font-weight: 700;
  color: var(--accent);
}
.sg-body {
  margin: 0;
  font-size: 0.88rem;
  line-height: 1.55;
  color: var(--color-gray-600);
}
.sg-foot {
  align-self: end;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--color-gray-900);
}

Every “Field note” sits on the same line, because the row it’s in was never sized by its own card - it was sized by the tallest one.

The whole idea

The parent grid declares three row bands - one for the eyebrow, one for the body, one for the footer - and each card is told to borrow them instead of inventing its own:

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: auto 1fr auto; /* eyebrow / body / footer */
  gap: 1rem;
}

.card {
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
}

grid-row: span 3 puts each card across all three of the parent’s row tracks. grid-template-rows: subgrid is the part that matters: instead of the card sizing its own three rows from its own content, it hands that decision back up to the parent. The parent then sizes each band by looking at the tallest content in that band, across every card at once - the eyebrow row becomes exactly as tall as the tallest eyebrow, the footer row exactly as tall as the tallest footer, and the middle row absorbs whatever’s left. A footer’s position stops being a property of its own card and becomes a property of the row it belongs to.

Why this beats “just make the cards the same height”

The instinctive flexbox fix - align-items: stretch - only equalizes the outer box. It makes all three cards the same overall height, but it does nothing to the space inside them. A card with less text still gets stretched to match its neighbors, but that extra space just piles up somewhere inside the card - usually between the body and the footer - so the footer itself can still land at a different height than a taller neighbor’s. Stretch matches outlines. Subgrid matches the actual rows inside them, which is the alignment you were after in the first place.

Where it stops working

Subgrid only makes sense between an element and a grid it’s genuinely a child of - you can’t reach two levels up, and you can’t link two unrelated grids together. It also arrived later than the rest of CSS grid, so if you’re supporting something unusually old it’s worth a quick check. Neither is a real obstacle today; it’s safe to build on directly - Firefox has had it the longest of any engine, in fact:

Browser support · subgrid

Chrome117+
Edge117+
Firefox71+
Safari16+

Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.

It’s the same category of fix as container queries: a real layout primitive replacing a pile of one-off patches, so a genuinely common problem - “line these up” - stops needing a workaround at all.

Back to all articles
Share

Keep Reading

Say hello

Let's make something
you're proud of.

Tell us what you're working on - even a rough idea is plenty. Book a quick call, or send a message and we'll reply like actual humans, usually within a day.

Book a call

A short intro call - no pitch, just a chat about what you're working on.

Prefer email?

office@otterdev.me

Based in

Montenegro

Working with people here, and anywhere the wifi reaches.

Prefer to write?