CSS Subgrid Tutorial: 3 Refactors That Delete Your Grid Hacks

2026-06-01 · Nico Brandt

Three lines of CSS you almost certainly have in production right now. min-height: 320px on a card class to stop a grid of them from looking ragged. width: 140px on form labels because they kept going jagged when a translation ran long. A magic-number top: 64px on a dashboard header because the content area couldn’t reach the page chrome.

Every one of those is a workaround for the same problem — grid not crossing nesting levels. This CSS subgrid tutorial deletes all three with before/after refactors you can ship today. Subgrid has been Baseline since March, sits at 97% global support, and your codebase is two PRs away from being lighter.

What subgrid actually does (in one paragraph)

CSS subgrid lets nested elements inherit their parent’s grid tracks, so deeply nested items align with siblings across the page — no JavaScript, no fixed heights, no grid hack workarounds. Supported in all major browsers since 2023.

The mechanic is one line. grid-template-columns: subgrid (or rows: subgrid) on a child grid tells it to use the parent’s tracks instead of defining its own. Gap and named lines inherit by default. Line numbers, though, reset to 1 inside the subgrid — which is going to matter later.

CSS subgrid browser support is no longer the conversation. Subgrid hit Baseline Widely Available in March 2026 and sits at around 97% global coverage. Chrome and Edge shipped it in 117 (September 2023), Firefox in 71 (December 2019), Safari in 16 (2022). The “we’ll add it when support improves” argument expired two years ago. The only reason it’s not in your codebase is that nobody’s done the refactor.

Pattern 1: Aligned card rows (delete your min-height hack)

You have a grid of cards. Each card has a title, body copy, and a CTA. Card C’s title wraps to two lines, so its body drops, its button drops, the bottom edges go ragged, and design files a ticket.

Here’s the css subgrid card layout hack you’re using right now:

.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
}
.card { min-height: 320px; } /* TODO: brittle, breaks when copy changes */
.card-title { height: 3rem; overflow: hidden; }

It works until the copy team adds one more word to a title. Then it doesn’t.

The subgrid replacement:

.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: auto auto auto;
  gap: 1rem;
}
.card {
  display: grid;
  grid-template-rows: subgrid;
  grid-row: span 3;
}

Three lines did the work. The parent declares three explicit row tracks — header, body, footer. Each card spans those three rows with grid-row: span 3, then opts into the parent’s row sizing with grid-template-rows: subgrid. Every card’s title row, body row, and footer row now sit on the same parent lines. Tallest title in the row sets the height for every card’s title. Tallest body sets every body. The footers line up because they have to.

Open DevTools, turn on the grid overlay, and the lines run straight across every card in the row. No fixed heights anywhere. Add a sentence to one card and the row grows; remove it and the row shrinks. The layout is doing what you always wanted it to do — and the comment-bug above the min-height finally goes away.

Cards are the obvious win. The less obvious one is when the elements you need to align aren’t siblings at all.

Pattern 2: Form label consistency (delete the fixed-width labels)

Multi-row form. Each row is a .field with a label and an input. Without intervention, every row sizes its label column independently and the inputs end up jagged.

The hack you’ve been shipping:

.field { display: flex; gap: 0.5rem; }
.field label { width: 140px; flex-shrink: 0; }
/* or worse: a JS pass that measures the widest label on load */

That magic number holds until you ship to a market that speaks German. Or design adds a longer field name. Or someone tests with browser zoom at 175%. Each one a ticket, each one a refactor.

The css subgrid nested grid alignment version puts the authority on the form itself:

.form {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.75rem 1rem;
}
.field {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: span 2;
}

Every .field spans two parent columns and inherits them. The parent’s first column is max-content, so it sizes to the widest label across the entire form — not per field, across the whole form. Add a row with a longer label and every other label column grows with it. Remove it and they shrink back. The inputs share an aligned right edge for free.

This is the pattern that survives translation without a designer touching anything. It’s also the pattern almost no other tutorial covers, because column subgrid feels weirder than row subgrid until you’ve shipped it once.

For mobile, swap the parent to grid-template-columns: 1fr inside a media or container query and the subgrid behavior degrades cleanly — labels stack above inputs, no extra CSS required. (If you’re already shipping container queries, they compose with subgrid without drama.)

Cards inherit rows. Forms inherit columns. The third pattern inherits both.

Pattern 3: Dashboard grid-of-grids (delete the magic-number positioning)

Admin dashboard. Sidebar on the left, header across the top, stat cards in the content area. The hack version uses a fixed 260px sidebar, an absolutely positioned header, and a content grid that declares its own columns and doesn’t know about anything else on the page. The stat cards never quite line up with the page chrome, which is the kind of thing you stop noticing until a designer points at it.

The subgrid architecture:

.app {
  display: grid;
  grid-template-columns:
    [sidebar-start] 260px
    [content-start] repeat(12, 1fr) [content-end];
  grid-template-rows: [header-start] 64px [main-start] 1fr;
  min-height: 100vh;
}
.header, .main, .stat-grid {
  display: grid;
  grid-template-columns: subgrid;
}
.header { grid-column: sidebar-start / content-end; grid-row: header-start; }
.sidebar { grid-column: sidebar-start; grid-row: header-start / -1; }
.main { grid-column: content-start / content-end; grid-row: main-start; }
.stat-grid { grid-column: content-start / content-end; }

The outer .app defines the entire page geometry once — a sidebar column plus twelve content columns, with named lines so you never count again. Header, main area, and stat grid each declare grid-template-columns: subgrid and span across the lines they need. A stat card three levels deep inside .main > .stat-grid > .card lands its left edge on content-start — the same line the header uses. Resize the sidebar by changing one value at the top of the file, and the header, main area, and every stat card move with it.

Stripe’s developer site does exactly this at full scale — a 24-column grid passed through multiple levels of subgrid. The reason it stays aligned no matter how deep the nesting goes is the same reason yours will: named lines inherit through subgrid. Line numbers don’t, though — they reset to 1 inside each subgrid. Which sets up the gotcha.

The one gotcha that will break your first attempt

The rule: a subgrid item must span the exact number of parent rows or columns its children need. Miss the count and the layout collapses into one row.

Broken version of Pattern 1:

.card {
  display: grid;
  grid-template-rows: subgrid;
  grid-row: span 1; /* wrong */
}

Title, body, and footer all stack into a single parent row. The card looks like a vertical accordion crammed into one cell. You’ll spend twenty minutes assuming subgrid is broken before realizing the card is only allowed one row to put three things into.

The fix is one character:

.card { grid-row: span 3; }

Two more worth knowing in one sentence each. First, auto-fill and auto-fit don’t work on the subgridded axis — use explicit repeat(12, 1fr) style track counts and stop fighting the spec. Second, margin, padding, and border on a subgrid container consume space from the inherited tracks, so a padding: 1rem on a .card will pull its inner alignment in by exactly that amount — your columns will drift and it’ll be the last thing you check.

Knowing when subgrid is the wrong tool matters as much as knowing when it’s right.

When to skip subgrid and use something else

Skip subgrid when the parent grid uses auto-fill or auto-fit for a fluid column count. The two features don’t compose — you’d lose either the fluid columns or the inheritance. Flexbox plus a nested grid ships cleaner.

Skip when the alignment you need is contained inside a single component and nothing outside cares. A nested grid with its own tracks ships faster and the parent grid stays oblivious — which is fine, because it doesn’t need to know.

Skip if you’re still supporting pre-2023 Chromium or any IE-era browser. At that point the fallback is more work than writing the non-subgrid version once:

@supports not (grid-template-columns: subgrid) {
  .card { display: flex; flex-direction: column; }
  .card-title { min-height: 3rem; }
}

For everyone else — roughly 97% of your traffic — the only thing left is the audit.

Go delete some CSS

Three hacks, three replacements.

min-height: 320px on cards → Pattern 1, three lines, gone. Fixed-width form labels → Pattern 2, the form sizes itself. Magic-number positioning on a dashboard → Pattern 3, named lines do the work.

Open your codebase. Grep for min-height on a card class. Replace it with the Pattern 1 refactor. Push the PR. The diff will be smaller than the comment explaining why the hack was there in the first place.

Subgrid has been Baseline since March. The only reason it’s not in your codebase yet is that nobody’s done the refactor. Be that PR.