Subgrid

Chapter 3 — Subgrid

For years, CSS Grid was excellent at aligning items within a single grid container, but completely powerless to align content across sibling grid items. Cards in a grid could not share internal row alignment — each card was its own island. You ended up hacking around this with display: contents, JavaScript height matching, or just accepting misaligned CTAs. Subgrid solves this properly: a nested element can opt into its ancestor's grid tracks and participate in the same line system.

Baseline 2023. grid-template-rows: subgrid and grid-template-columns: subgrid are supported in all evergreen browsers since late 2023. Safari shipped it first (2022), Firefox in 2019, Chrome in 2023. There are no polyfills — it requires a real subgrid implementation.

1. The Problem Subgrid Solves

A card grid with varying content lengths produces misaligned internal sections when each card manages its own layout. The card with the longest description pushes its CTA button lower, breaking visual alignment across the row.

Without subgrid — CTAs misaligned
Card One
Short description.
Read more
Card Two
A much longer description that spans several lines and pushes the button further down than the others.
Read more
Card Three
Medium length text here.
Read more
CTAs at different heights — no shared row track
With subgrid — all CTAs aligned
Card One
Short description.
Read more
Card Two
A much longer description that spans several lines and pushes the button further down.
Read more
Card Three
Medium length text here.
Read more
All CTAs pinned to the same row track via subgrid

The right panel above is live — the cards are using grid-template-rows: subgrid to participate in the outer grid's row tracks. All CTAs sit on the same track regardless of how long the description text is.

2. How Subgrid Works

When a grid item has grid-template-rows: subgrid (or grid-template-columns: subgrid), it becomes a subgrid container. Its children are placed into the parent grid's tracks for that axis — they don't create new independent tracks. The parent grid determines track sizes; the subgrid just borrows them.

parent grid — defines 3 columns, 4 row tracks
image
title
description text
can be long
CTA
image
title
very long description that wraps to more lines than the others in this row
CTA
image
title
short
CTA
image track — same height for all
title track — same height for all
body track — grows to tallest content
CTA track — pinned to same baseline
/* ── The canonical card-grid subgrid pattern ─────────────────── */ .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); /* DO NOT define row tracks here — let the cards define them via content */ /* The parent just needs auto rows; subgrid inherits whatever size they become */ grid-auto-rows: auto; gap: 1rem; } .card { /* Span 4 rows of the parent grid (one per internal section) */ grid-row: span 4; /* Become a subgrid on the row axis */ display: grid; grid-template-rows: subgrid; /* gap on a subgrid uses the PARENT's row gap — override if needed */ gap: 0.5rem; padding: 1rem; background: var(--surface); border-radius: 8px; } /* No special CSS needed on the card children — they just flow into the 4 subgrid rows */ /* Each child occupies one row track automatically */ .card__image { /* row 1 — fixed image height via min-height or aspect-ratio */ aspect-ratio: 16 / 9; } .card__title { align-self: start; } /* row 2 */ .card__description { } /* row 3 — grows to tallest in group */ .card__cta { align-self: end; } /* row 4 — pinned to bottom of track */

3. Axis Independence

Subgrid can be applied to rows, columns, or both axes independently. Mixing a subgrid axis with an explicit-tracks axis on the other axis is fully valid.

/* Subgrid on rows only — most common */ .card { display: grid; grid-row: span 4; grid-template-rows: subgrid; /* rows come from parent */ grid-template-columns: auto 1fr; /* columns are independent */ } /* Subgrid on columns only — useful for nested navigation lists */ .nav-group { display: grid; grid-column: span 3; grid-template-columns: subgrid; /* columns come from parent */ } /* Subgrid on BOTH axes — card participates in parent rows and columns */ .feature-block { display: grid; grid-column: span 2; grid-row: span 3; grid-template-columns: subgrid; grid-template-rows: subgrid; } /* Mixing explicit tracks on the subgrid axis is NOT valid */ /* grid-template-rows: subgrid 100px → invalid — it's subgrid or explicit, not both */ /* The number of subgrid tracks is determined by the span, not by additional values */

4. Gap Behaviour in Subgrid

/* By default, a subgrid inherits the parent's gap for its subgrid axis */ .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); row-gap: 2rem; /* gap between cards — subgrid children inherit this */ column-gap: 1rem; } .card { display: grid; grid-row: span 4; grid-template-rows: subgrid; row-gap: 0.5rem; /* OVERRIDES parent gap within this subgrid */ /* Without this, the parent's 2rem row-gap would appear between card sections */ } /* Summary of gap rules: */ /* 1. A subgrid axis inherits the parent's gap for that axis by default */ /* 2. Setting gap on the subgrid element overrides for the subgrid's children */ /* 3. The NON-subgrid axis uses the subgrid element's own gap setting */ /* Practical consequence: if your card's internal spacing looks too large, */ /* set a smaller row-gap on the card element to override the parent's row-gap */

5. Named Grid Lines in Subgrid

The parent grid's named lines are inherited by the subgrid. Any names you define on the parent's tracks are available inside the subgrid to position children. You can also define additional names on the subgrid element itself — they are layered on top of the inherited names.

/* Parent defines named row tracks */ .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); /* Named lines on the implicit row axis */ /* No explicit row template — rows are implicit (auto) */ } /* Alternatively: explicit named rows on the parent */ .card-grid--named { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: [card-image-start] auto [card-image-end card-title-start] auto [card-title-end card-body-start] auto [card-body-end card-cta-start] auto [card-cta-end]; } .card { display: grid; grid-row: card-image-start / card-cta-end; grid-template-rows: subgrid; } /* Subgrid children can now use the inherited named lines */ .card__image { grid-row: card-image-start / card-image-end; } .card__title { grid-row: card-title-start / card-title-end; } .card__body { grid-row: card-body-start / card-body-end; } .card__cta { grid-row: card-cta-start / card-cta-end; } /* Adding names to the subgrid element itself */ .card { display: grid; grid-row: span 4; grid-template-rows: subgrid [inner-start] [img-end] [body-start] [inner-end]; /* The bracket names here apply to the tracks within the subgrid */ /* They are layered on top of any inherited names from the parent */ }

6. Subgrid with auto-fill and auto-fit

/* Subgrid works with auto-fill column grids — cards wrap to new rows */ /* Each row group of cards shares row track sizes */ .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; } .card { display: grid; grid-row: span 4; grid-template-rows: subgrid; row-gap: 0.75rem; } /* Important: with auto-fill, the number of columns per row is dynamic */ /* At 600px wide: 2 cards per row → they share 4 row tracks in that group At 1200px wide: 4 cards per row → different group composition Row tracks are sized per row — cards in the same row share, not cards across rows This is correct behaviour: you want alignment within a visual row, not the whole grid /* Limitation: you cannot guarantee that specific named cards appear in the same row — the auto-placement algorithm controls that. For curated layouts, use explicit grid-row placement alongside subgrid. */

7. Nested Subgrid

/* A subgrid element's children can themselves be subgrids */ /* Each level inherits from its parent's tracks */ .page { display: grid; grid-template-columns: repeat(12, 1fr); /* 12-column layout grid */ column-gap: 1rem; } .article { grid-column: 1 / -1; /* spans all 12 columns */ display: grid; grid-template-columns: subgrid; /* inherits the 12-column grid */ } .article__sidebar { grid-column: 1 / 4; /* columns 1–3 */ display: grid; grid-template-columns: subgrid; /* inherits 3 of the 12 columns */ } .widget { grid-column: 1 / 3; /* within the sidebar's 3 columns */ display: grid; grid-template-columns: subgrid; /* inherits 2 of the sidebar's 3 columns */ /* which are also 2 of the root 12 — all are the same tracks */ } /* The entire nested tree participates in the root 12-column system */ /* Deeply nested content can be aligned to the root grid without any custom widths */

8. Implicit Tracks and Subgrid

/* Subgrid only covers EXPLICIT parent tracks that the item spans */ /* It cannot extend into implicit tracks created by auto-placement */ .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: auto; /* implicit rows — subgrid can use these */ } .card { display: grid; grid-row: span 4; /* spans 4 implicit rows */ grid-template-rows: subgrid; /* inherits those 4 implicit rows */ /* This works even though the rows are implicit — they're just auto rows */ } /* What does NOT work: trying to subgrid more tracks than you span */ .card { grid-row: span 4; grid-template-rows: subgrid; /* inherits exactly 4 rows — the span */ /* A 5th child would overflow into the card's block axis, not get a subgrid track */ } /* Rule: span = number of subgrid tracks available */ /* Children beyond that number are placed in implicit tracks ON THE SUBGRID ELEMENT */ /* (not on the parent grid) — they break alignment */ /* Always match your span to the number of children you have */

9. Alignment within Subgrid

/* align-items / justify-items on the PARENT apply to all items including subgrid children */ .card-grid { align-items: start; /* affects the card elements (grid items) */ } /* align-items on the SUBGRID element applies to ITS children (subgrid items) */ .card { display: grid; grid-row: span 4; grid-template-rows: subgrid; align-items: stretch; /* all card sections stretch to fill their track */ } /* Override per section */ .card__image { align-self: stretch; object-fit: cover; } .card__title { align-self: end; /* title sits at the bottom of its track */ } .card__body { align-self: start; /* body text starts at top of its (expandable) track */ } .card__cta { align-self: end; /* CTA button anchored to bottom of its track */ } /* Writing the CTA pattern without subgrid for comparison */ /* Old pattern: display:flex flex-direction:column on the card + margin-top:auto on CTA */ .card--flex { display: flex; flex-direction: column; } .card--flex .card__cta { margin-top: auto; /* pushes CTA to bottom within its card — but still misaligned ACROSS cards */ } /* Subgrid is the only pure-CSS way to align CTA buttons ACROSS sibling cards */

10. Real-World Patterns

Form layout with label–input pairs

/* Labels and inputs aligned across a two-column parent */ .form { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; } .form__group { display: grid; grid-row: span 2; /* label + input = 2 tracks */ grid-template-rows: subgrid; row-gap: 4px; /* tight gap between label and input */ } /* All labels align to one row track, all inputs to the next */ /* regardless of label text length — no flexbox hacks needed */ .form__group label { align-self: end; } /* label sits at bottom of first track */ .form__group input { align-self: stretch; } /* input fills second track */ /* Full-width field that spans both columns */ .form__group--full { grid-column: 1 / -1; display: grid; grid-column-span: subgrid; /* spans both columns — subgrids them too */ grid-row: span 2; grid-template-rows: subgrid; }

12-column macro layout with nested sections

/* Classic editorial layout: sidebar + content sharing a column grid */ .page { display: grid; grid-template-columns: [full-start] minmax(1rem, 1fr) [content-start] repeat(10, 1fr) [content-end] minmax(1rem, 1fr) [full-end]; } .article { grid-column: content-start / content-end; display: grid; grid-template-columns: subgrid; } .article__body { grid-column: 1 / 8; /* 7 of the 10 content columns */ } .article__aside { grid-column: 8 / -1; /* remaining 3 columns */ } /* A pull-quote that bleeds into the margin */ .pull-quote { grid-column: full-start / 5; /* bleeds into the margin column */ }

Navigation with icon + label pairs

/* Sidebar nav: icons aligned in one column, labels in another */ .nav { display: grid; grid-template-columns: 40px 1fr; /* icon width | label */ row-gap: 4px; } .nav__item { display: grid; grid-column: 1 / -1; /* spans both nav columns */ grid-template-columns: subgrid; /* icon | label from parent */ align-items: center; padding: 8px 12px; border-radius: 6px; } /* All icons align to the exact same 40px column regardless of icon size variation */ /* All labels start at the exact same column line */ /* Previously required explicit width:40px on every icon element */

11. Property Reference

Property / valueWhere it goesWhat it does
grid-template-rows: subgrid On the grid item (subgrid container) Row tracks are inherited from the parent grid. Number of tracks = grid-row span.
grid-template-columns: subgrid On the grid item (subgrid container) Column tracks are inherited from the parent grid. Number of tracks = grid-column span.
grid-row: span N On the grid item Defines how many parent row tracks this item occupies — and therefore how many tracks the subgrid inherits.
row-gap / column-gap On the subgrid container Overrides the parent's gap for that axis within this subgrid. Default is to inherit the parent's gap.
align-items On the subgrid container Controls block-axis alignment of the subgrid's own children within their inherited tracks.
align-self On a subgrid child Overrides alignment for one child within its inherited track — typically start, end, center, or stretch.
display: contents On a grid item Older workaround — collapses the element's box so its children become direct grid items. Has accessibility issues and doesn't support gap. Use subgrid instead.
display: contents is not the same as subgrid. display: contents removes the intermediate element from the box tree entirely — its children become grid items of the parent, but the element itself disappears, losing its background, padding, and border. It also breaks accessibility in some browsers by stripping the element from the accessibility tree. Subgrid keeps the intermediate element as a real box while letting its children align to the parent grid.

Chapter Summary

ConceptKey point
What subgrid doesA grid item with grid-template-rows/columns: subgrid lets its children participate in the parent grid's tracks, sharing row or column sizing with siblings.
span = track countThe number of subgrid tracks available equals the element's grid-row (or grid-column) span. Set the span before adding subgrid.
Axis independenceSubgrid can apply to rows, columns, or both. One axis can be subgrid while the other uses explicit tracks.
Gap inheritanceA subgrid inherits the parent's gap for its subgrid axis by default. Override with row-gap / column-gap on the subgrid element.
Named linesParent named lines are inherited by the subgrid. Additional names can be declared on the subgrid element using bracket notation alongside the subgrid keyword.
auto-fill gridsSubgrid works with auto-fill. Each row group shares track sizes across its members. Alignment is within a visual row, not across all rows.
Nested subgridA subgrid's children can themselves be subgrids. Each level inherits from its parent, which may itself be inheriting from a higher ancestor.
Implicit tracksSubgrid can inherit implicit tracks (created by grid-auto-rows) as well as explicit ones. Children beyond the span don't get inherited tracks.
vs display: contentsdisplay: contents was the old workaround — it collapses the element's box, removes it from accessibility tree, and loses padding/background. Subgrid is the correct solution.
Exercises
  1. Card grid with four tracks: Create a three-column card grid where each card has exactly four sections: image, title, description, and a "Read more" link. Use grid-template-rows: subgrid and grid-row: span 4 on each card. Populate the cards with different-length descriptions and confirm that all "Read more" links sit on the same horizontal baseline. Then remove subgrid and use margin-top: auto on the CTA instead — observe the difference when multiple cards appear in the same row.
  2. Gap override: Set a row-gap: 2rem on the parent grid for large row spacing between card groups. Override the gap to 0.5rem on the subgrid card element for tighter internal spacing. Confirm that the large gap appears between card rows but not between sections within a card. Then remove the override and describe what happens to the card's internal layout.
  3. 12-column macro layout: Build a page wrapper with a named 12-column grid (with named margin columns using minmax). Create an .article that spans the central columns using grid-template-columns: subgrid. Inside the article, place a body column (7 columns wide), an aside (3 columns), and a pull-quote that bleeds one column into the margin. Use only named grid lines for placement — no numeric column positions on the article's children.
  4. Form label alignment: Build a two-column form where each field group contains a label and an input. Use grid-row: span 2 and grid-template-rows: subgrid on each group so that all labels share one row track and all inputs share the next. Add a full-width textarea group that spans both columns. Confirm that the label baseline aligns horizontally across both columns.
  5. Nested subgrid: Create a three-level subgrid: a 12-column root page grid, a section that subgrids it for 8 columns, and a nested component inside the section that further subgrids 4 of those 8 columns. Place content inside the innermost subgrid and confirm it aligns to the original root 12-column lines. Inspect with DevTools grid overlay to verify all three levels share the same track lines.
Next: Chapter 4 — The :has() Selector and Advanced Selector Patterns. The parent selector in depth — how :has() interacts with specificity, performance, pseudo-classes, and combinators. Advanced selector patterns: :is(), :where(), :not() with complex arguments, :nth-child(An+B of selector), and building quantity queries without JavaScript.