CSS Module 11 – CSS Grid
Subgrid: aligning nested content
Line up titles, texts and buttons across a row of cards with CSS subgrid: share the parent grid's rows or columns, override gaps and pass line names down.
What you will learn
- Use subgrid in grid-template-rows and grid-template-columns so nested grids reuse their parent's tracks
- Arrange card titles, bodies and buttons in shared rows across a row of cards
- Combine subgrid with gap overrides and named lines
Before you start
What you’ll learn · Before you start
What you will learn
- Use subgrid in grid-template-rows and grid-template-columns so nested grids reuse their parent's tracks
- Arrange card titles, bodies and buttons in shared rows across a row of cards
- Combine subgrid with gap overrides and named lines
Before you start
On this page
A subgrid is a grid item that lays its own children out in its parent’s tracks instead of making tracks of its
own. Set grid-template-rows: subgrid on a card that spans three rows of the page’s grid, and the card’s title, text
and button sit in those three shared rows, so every card’s title row is as tall as the tallest title and the texts and
buttons line up across the whole row of cards. grid-template-columns: subgrid does the same for columns, for example
to line labels and inputs up across separate groups of a form. Before subgrid, that alignment needed fixed heights or
JavaScript that measured the cards. Subgrid is Baseline widely available (Chrome 117, Firefox 71 and Safari 16 were
the first versions to support it). The cards, the gaps, an overflow and a form all come with their numbers from
Chromium 153, Firefox 155 and WebKit 26.6.
The problem, measured
Three product cards in a row, each its own small grid of three rows (auto 1fr auto), with titles and texts of
different lengths:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Three product cards, with and without subgrid</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
h3, p { margin: 0; }
.cards {
display: grid;
inline-size: 960px;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-block-end: 32px;
}
.card { padding: 16px; background: #eef; }
.card a { display: block; padding: 8px; background: #dde; text-align: center; }
/* Each card is its own grid: its rows know nothing about the other cards. */
#nested .card { display: grid; grid-template-rows: auto 1fr auto; row-gap: 8px; }
/* Each card spans three rows of the parent and uses them for its own children. */
#subgrid .card {
grid-row: span 3;
display: grid;
grid-template-rows: subgrid;
row-gap: 8px; /* the subgrid's own gap; without it, it would use the parent's 16px */
}
</style>
</head>
<body>
<div class="cards" id="nested">
<article class="card"><h3>Cotton kurta</h3><p>Hand-stitched, in five colours.</p><a href="#a">Add to cart</a></article>
<article class="card"><h3>Handloom silk saree with a zari border</h3><p>Woven in Kanchipuram. Comes with a matching blouse piece and a gift box.</p><a href="#b">Add to cart</a></article>
<article class="card"><h3>Block-printed dupatta</h3><p>Natural dyes.</p><a href="#c">Add to cart</a></article>
</div>
<div class="cards" id="subgrid">
<article class="card"><h3>Cotton kurta</h3><p>Hand-stitched, in five colours.</p><a href="#a">Add to cart</a></article>
<article class="card"><h3>Handloom silk saree with a zari border</h3><p>Woven in Kanchipuram. Comes with a matching blouse piece and a gift box.</p><a href="#b">Add to cart</a></article>
<article class="card"><h3>Block-printed dupatta</h3><p>Natural dyes.</p><a href="#c">Add to cart</a></article>
</div>
<script>
// For each card: how far below the card's top edge its title, text and button start, and how tall each is.
const round = (n) => Math.round(n * 100) / 100;
for (const grid of document.querySelectorAll('.cards')) {
console.log(`${grid.id}: rows ${getComputedStyle(grid).gridTemplateRows}`);
for (const card of grid.children) {
const top = card.getBoundingClientRect().top;
const parts = [...card.children].map((el) => {
const r = el.getBoundingClientRect();
return `${el.tagName.toLowerCase()} at ${round(r.top - top)} (${round(r.height)} tall)`;
});
console.log(` ${card.querySelector('h3').textContent.split(' ')[0]}…: ${parts.join(', ')}; card ${round(card.getBoundingClientRect().height)}px`);
}
}
</script>
</body>
</html> The first grid of the example is the usual approach. How far below each card’s top edge its parts started, in all three engines:
| Card | Title | Text | Button |
|---|---|---|---|
| Kurta | 16 px | 48 px | 128 px |
| Saree | 16 px | 72 px | 128 px |
| Dupatta | 16 px | 48 px | 128 px |
The buttons line up, because the cards stretch to the height of the tallest one and the 1fr row takes the extra
space. The texts do not: the saree’s two-line title pushes its text 24 px lower than its neighbours’, because each
card sizes its rows only from its own content. A design that lines prices or ratings up across cards has the same
problem with every part except the last.
Sharing the parent’s rows with subgrid
In the second grid of the example, each card does three things: it spans three rows of the parent
(grid-row: span 3), it is a grid (display: grid), and it takes its rows from the parent
(grid-template-rows: subgrid) instead of auto 1fr auto. It also sets its own row-gap: 8px, because a subgrid
otherwise uses its parent’s gaps, 16 px here.
Three product cards as separate grids and as subgrids
Text description of the diagram
Two rows of three product cards, drawn to scale from the lesson's measurements. Each card has a title, a text and a button.
- In the first row each card is its own grid. The first and third cards have one-line titles, so their texts start 48 px below the top of the card, while the middle card's two-line title pushes its text down to 72 px. A dashed line at 72 px shows that the texts do not line up. The buttons do line up, at 128 px, because the cards stretch to the same height and the text row takes the extra space.
- In the second row the cards use subgrid and share the parent's rows. Every title row is as tall as the tallest title, so all three texts start at 72 px and all three buttons at 128 px, along two dashed lines.
- A note under the cards repeats the numbers: alone, the texts start at 48, 72 and 48 px; on shared rows, all three start at 72 px.
All three engines then started every title at 16 px, every text at 72 px and every button at 128 px, and reported the
parent’s rows as 60px 40px 52px. Those numbers come from three rules: each shared row is as tall as the tallest
section in it; the card’s padding counts as extra margin on the sections at its top and bottom edges; and half the
difference between the card’s gap and the parent’s, (8 − 16) ÷ 2 = −4 px, is added as margin on the sides that face
another section. A program that applies them:
// Where the title, text and button of each product card start, without and with subgrid (CSS Grid Level 2,
// "Subgrids"). The heights are the content heights the browsers measured: 24px per line of text, 40px buttons.
// Each card has 16px of padding; the parent's gap is 16px and the card's own row-gap 8px.
const cards = [
{ name: 'Cotton kurta', title: 24, text: 24, button: 40 },
{ name: 'Handloom silk saree', title: 48, text: 48, button: 40 },
{ name: 'Block-printed dupatta', title: 24, text: 24, button: 40 },
];
const padding = 16, parentGap = 16, cardGap = 8;
// Without subgrid: each card is a grid of auto, 1fr and auto rows, stretched to the height of the tallest card.
const natural = (c) => padding + c.title + cardGap + c.text + cardGap + c.button + padding;
const height = Math.max(...cards.map(natural));
console.log(`each card its own grid: every card stretched to ${height}px`);
for (const c of cards) {
console.log(` ${c.name.padEnd(22)} title at ${padding}, text at ${padding + c.title + cardGap}, button at ${height - padding - c.button}`);
}
// With subgrid: the parent's rows hold every card's sections. The card's padding counts as extra margin on the
// sections at its edges, and half the difference between the two gaps (8 - 16) / 2 = -4px as margin on the inside.
const half = (cardGap - parentGap) / 2;
const rows = [
Math.max(...cards.map((c) => c.title)) + padding + half,
Math.max(...cards.map((c) => c.text)) + 2 * half,
Math.max(...cards.map((c) => c.button)) + half + padding,
];
const textTop = rows[0] + parentGap + half;
const buttonTop = rows[0] + parentGap + rows[1] + parentGap + half;
console.log(`subgrid: the parent's rows are ${rows.join(', ')}px, shared by every card`);
console.log(` every card title at ${padding}, text at ${textTop}, button at ${buttonTop}`); Output
each card its own grid: every card stretched to 184px Cotton kurta title at 16, text at 48, button at 128 Handloom silk saree title at 16, text at 72, button at 128 Block-printed dupatta title at 16, text at 48, button at 128 subgrid: the parent's rows are 60, 40, 52px, shared by every card every card title at 16, text at 72, button at 128
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node shared_rows.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The parent has no grid-template-rows at all: the browser creates three rows for each card it places, sized by the
cards’ contents. With more cards than columns, the next row of cards gets three new rows of its own, so each row of
cards lines up inside itself.
How a subgrid behaves
A few rules from the specification decide what a subgrid can and cannot do:
- It has no implicit tracks in the subgridded axis. If it has more children than the tracks it spans, the extra ones are placed in its last track, on top of whatever is there.
- It uses its parent’s gaps unless it sets its own, and an override changes only the spacing of its own items.
- Line names pass down. Named lines of the parent, and the lines its named areas create, can be used inside the
subgrid, so a child can still be placed with
grid-column: content. A subgrid can add names of its own after the keyword, as ingrid-template-columns: subgrid [label] [field]. - It has no track sizes of its own in that axis: the tracks, and their sizes, belong to the parent. The other axis
can be an ordinary grid, or a subgrid too (
grid-template: subgrid / subgrid).
The first rule is the one that breaks layouts later. The cards above were written with three parts; then a content editor adds a badge to each card:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A fourth child in a three-row subgrid</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
h3, p { margin: 0; }
.cards { display: grid; inline-size: 640px; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-block-end: 32px; }
.card { display: grid; grid-template-rows: subgrid; row-gap: 8px; padding: 16px; background: #eef; }
.card a { display: block; padding: 8px; background: #dde; text-align: center; }
.badge { background: #fd8; }
#three-rows .card { grid-row: span 3; } /* written when the cards had three parts */
#four-rows .card { grid-row: span 4; } /* one row for each part */
</style>
</head>
<body>
<div class="cards" id="three-rows">
<article class="card"><h3>Cotton kurta</h3><p>Hand-stitched.</p><a href="#a">Add to cart</a><span class="badge">Bestseller</span></article>
<article class="card"><h3>Block-printed dupatta</h3><p>Natural dyes.</p><a href="#b">Add to cart</a><span class="badge">New</span></article>
</div>
<div class="cards" id="four-rows">
<article class="card"><h3>Cotton kurta</h3><p>Hand-stitched.</p><a href="#a">Add to cart</a><span class="badge">Bestseller</span></article>
<article class="card"><h3>Block-printed dupatta</h3><p>Natural dyes.</p><a href="#b">Add to cart</a><span class="badge">New</span></article>
</div>
<script>
const round = (n) => Math.round(n * 100) / 100;
for (const grid of document.querySelectorAll('.cards')) {
const card = grid.firstElementChild;
const top = card.getBoundingClientRect().top;
const parts = [...card.children].map((el) => {
const r = el.getBoundingClientRect();
return `${el.className || el.tagName.toLowerCase()} ${round(r.top - top)} to ${round(r.bottom - top)}`;
});
console.log(`${grid.id}: parent rows ${getComputedStyle(grid).gridTemplateRows}; first card: ${parts.join(', ')}`);
}
</script>
</body>
</html> | Card spans | Button | Badge |
|---|---|---|
| 3 rows | 80 to 120 px | 80 to 120 px: on top of the button |
| 4 rows | 80 to 120 px | 128 to 152 px: its own row |
All three engines drew the badge exactly over the button in a three-row subgrid. The fix is to make the span match the
number of parts, and to keep that number in one place. Setting it with a custom property, such as grid-row: span var(--parts, 3), lets a card with an extra part say so without editing the grid rules.
Columns: one label column across groups
Forms group their fields, and each group is usually its own grid, so each group’s label column fits only its own
labels. Sharing the form’s columns lines every input up. Three versions of one form, each 560 px wide, with a
max-content label column and a 1fr field column:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Labels and inputs aligned across groups</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
form { display: grid; inline-size: 560px; grid-template-columns: max-content 1fr; gap: 12px 16px; margin-block-end: 32px; }
.group { grid-column: 1 / -1; display: grid; gap: inherit; margin: 0; }
.group h3 { grid-column: 1 / -1; margin: 0; font-size: 1em; }
input { font: inherit; }
#separate .group { grid-template-columns: max-content 1fr; } /* each group its own two columns */
#fieldsets .group { grid-template-columns: subgrid; } /* fieldsets on the form's columns */
#divs .group { grid-template-columns: subgrid; } /* role="group" divs on the form's columns */
</style>
</head>
<body>
<form id="separate">
<fieldset class="group"><legend>You</legend>
<label for="s-name">Name</label><input id="s-name" autocomplete="name">
<label for="s-email">Email</label><input id="s-email" type="email" autocomplete="email">
</fieldset>
<fieldset class="group"><legend>Delivery</legend>
<label for="s-address">Delivery address</label><input id="s-address" autocomplete="street-address">
<label for="s-pin">PIN code</label><input id="s-pin" inputmode="numeric" autocomplete="postal-code">
</fieldset>
</form>
<form id="fieldsets">
<fieldset class="group"><legend>You</legend>
<label for="f-name">Name</label><input id="f-name" autocomplete="name">
<label for="f-email">Email</label><input id="f-email" type="email" autocomplete="email">
</fieldset>
<fieldset class="group"><legend>Delivery</legend>
<label for="f-address">Delivery address</label><input id="f-address" autocomplete="street-address">
<label for="f-pin">PIN code</label><input id="f-pin" inputmode="numeric" autocomplete="postal-code">
</fieldset>
</form>
<form id="divs">
<div class="group" role="group" aria-labelledby="d-you"><h3 id="d-you">You</h3>
<label for="d-name">Name</label><input id="d-name" autocomplete="name">
<label for="d-email">Email</label><input id="d-email" type="email" autocomplete="email">
</div>
<div class="group" role="group" aria-labelledby="d-delivery"><h3 id="d-delivery">Delivery</h3>
<label for="d-address">Delivery address</label><input id="d-address" autocomplete="street-address">
<label for="d-pin">PIN code</label><input id="d-pin" inputmode="numeric" autocomplete="postal-code">
</div>
</form>
<script>
// The left edge of every input, from the form's left edge, and whether each input sits beside its label.
const round = (n) => Math.round(n * 100) / 100;
for (const form of document.querySelectorAll('form')) {
const left = form.getBoundingClientRect().left;
const inputs = [...form.querySelectorAll('input')];
const beside = inputs.every((i) => Math.abs(i.getBoundingClientRect().top - i.previousElementSibling.getBoundingClientRect().top) < 4);
const edges = inputs.map((i) => `${i.id.slice(2)} ${round(i.getBoundingClientRect().left - left)}`);
console.log(`${form.id}: inputs start at ${edges.join(', ')}; ${beside ? 'each beside its label' : 'stacked under their labels'}`);
}
</script>
</body>
</html> Where the inputs started, measured from the form’s left edge:
| Version | Chromium 153 | Firefox and WebKit |
|---|---|---|
| Fieldsets, own grids | 72.69 and 149.16 px | the same, to 0.01 px |
| Fieldsets, subgrid | stacked in one column | all at 149.16 px |
| Groups, subgrid | all at 135.16 px | the same, to 0.01 px |
The groups in the last row are div elements with role="group".
Separate grids left the first group’s inputs 76 px to the left of the second group’s. As subgrids, the fieldsets lined
every input up in Firefox and WebKit, but Chromium 153 did not treat the fieldset as a subgrid: its labels and inputs
fell into a single column. Browsers lay a fieldset out with an anonymous inner box, and that is the kind of special case
where engines still differ. The div version worked in all three: a div with role="group" and
aria-labelledby pointing to its heading gives screen readers the group and its name, as a fieldset and its legend
would. If you keep fieldsets, a fixed label column on each one, such as grid-template-columns: 10rem 1fr, aligns the
inputs without a subgrid.
Support and fallbacks
Subgrid works in every current engine, so most sites can use it without a fallback. A site that must also serve older
browsers can test for it with a feature query, @supports (grid-template-rows: subgrid), and keep a simpler layout
for the others: each card a flex column with margin-block-start: auto on its button. That fallback lines up the
buttons but not the texts, which is acceptable as a lesser layout. Fixed heights are the fallback to avoid: they break
as soon as a text is longer than expected, after a translation or at a larger font size, and the text spills out of
its box.
Practise: predict the shared rows
Exercise · Medium · JavaScript
Predict the shared rows of subgrid cards
A row of product cards sits in a grid. Each card spans one parent row per section (title, price, details, button) and sets grid-template-rows: subgrid, so its sections sit in the parent's rows. Each card has padding px of padding, the parent has a row gap of parentGap px and each card sets its own row-gap of cardGap px. Write three functions in cards.mjs and export them. All lengths are in px.
The first, sharedRows(cards, { padding, parentGap, cardGap }), returns the height of each parent row. cards is a list of cards, each a list of its sections' content heights, in order. A row is as tall as the tallest section in it, plus the extra margins the subgrid rules add: the card's padding on the side of a section that touches the card's top or bottom edge, and half the difference between the gaps, (cardGap - parentGap) / 2, on every side that faces another section. That half may be negative.
The second, sectionTops(rows, { padding, parentGap, cardGap }), returns how far below a card's top edge each section starts. The first section starts after the padding; every other section starts where its row starts, plus the same half difference.
The third, landingRows(children, span), returns the row (1, 2, 3 …) that each of a card's children auto-placed children lands in when the card spans span rows. A subgrid adds no rows of its own, so children that do not fit are all placed in its last row.
The sample tests import the three functions from cards.mjs and run in your browser.
Starter code · cards.mjs
/** The height of each parent row that the cards' sections share. */
export function sharedRows(cards, { padding, parentGap, cardGap }) {
// Replace this line with your code.
return cards[0];
}
/** How far below a card's top edge each section starts. */
export function sectionTops(rows, { padding, parentGap, cardGap }) {
// Replace this line with your code.
return rows.map(() => padding);
}
/** The row each of a card's children lands in when the card spans `span` rows. */
export function landingRows(children, span) {
// Replace this line with your code.
return Array.from({ length: children }, (_, i) => i + 1);
} The sample tests · cards.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { sharedRows, sectionTops, landingRows } from './cards.mjs';
const lesson = { padding: 16, parentGap: 16, cardGap: 8 };
test('the lesson cards: rows of 60, 40 and 52px', () => {
const cards = [[24, 24, 40], [48, 48, 40], [24, 24, 40]];
assert.deepEqual(sharedRows(cards, lesson), [60, 40, 52]);
assert.deepEqual(sectionTops([60, 40, 52], lesson), [16, 72, 128]);
});
test('four sections in four cards', () => {
const cards = [[24, 32, 48, 40], [48, 32, 96, 40], [24, 32, 72, 40], [72, 32, 24, 40]];
const rows = sharedRows(cards, lesson);
assert.deepEqual(rows, [84, 24, 88, 52]);
assert.deepEqual(sectionTops(rows, lesson), [16, 96, 136, 240]);
});
test('the same gap inside and outside adds nothing', () => {
const same = { padding: 12, parentGap: 16, cardGap: 16 };
const rows = sharedRows([[24, 24, 40], [48, 24, 40]], same);
assert.deepEqual(rows, [60, 24, 52]);
assert.deepEqual(sectionTops(rows, same), [12, 76, 116]);
});
test('a wider gap inside the card than outside', () => {
const wide = { padding: 16, parentGap: 8, cardGap: 24 };
assert.deepEqual(sharedRows([[24, 24, 40]], wide), [48, 40, 64]);
assert.deepEqual(sectionTops([48, 40, 64], wide), [16, 64, 112]);
});
test('a card of one section gets both paddings', () => {
assert.deepEqual(sharedRows([[30], [50]], lesson), [82]);
assert.deepEqual(sectionTops([82], lesson), [16]);
});
test('children beyond the span land in the last row', () => {
assert.deepEqual(landingRows(4, 3), [1, 2, 3, 3]);
assert.deepEqual(landingRows(3, 3), [1, 2, 3]);
assert.deepEqual(landingRows(5, 2), [1, 2, 2, 2, 2]);
assert.deepEqual(landingRows(2, 4), [1, 2]);
}); A hint
With n sections, row i gets padding on its top side when i is 0 and half otherwise, and padding on its bottom side when i is n - 1 and half otherwise. Row i starts at the sum of the rows before it plus i parent gaps.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Interview questions
Fresher · What is a subgrid, and how is it different from a nested grid?
A nested grid is a grid item that is also a grid container with tracks of its own, sized only by its own children, so
two cards side by side cannot line their insides up. A subgrid is a grid item that sets grid-template-rows: subgrid
or grid-template-columns: subgrid, or both: it has no tracks of its own in that axis and lays its children out in
the parent tracks it spans. Its children then take part in sizing those parent tracks, so a long title in one card
makes the title row taller for every card. A subgrid keeps its own box, padding and background, inherits the parent’s
gaps unless it overrides them, sees the parent’s line names, and adds no implicit tracks, so it must span as many
tracks as it has children in that axis.
Key takeaways
grid-template-rows: subgridon an item that spans parent rows puts its children in those rows, and their content sizes them, so sections line up across cards.- Span exactly as many tracks as the subgrid has children: extra children land in the last track, on top of others.
- A subgrid uses its parent’s gaps unless it sets its own; half the difference is applied as margin inside it.
- The subgrid’s padding counts as extra margin on its edge items, which is why the measured rows were 60, 40 and 52 px.
- Line names of the parent pass down, and a subgrid can add its own after the
subgridkeyword. - In the lesson’s run, Chromium 153 did not make a
fieldseta subgrid; adivwithrole="group"worked in all three engines. - Subgrid is Baseline widely available; a flex-column fallback inside
@supportsloses only the alignment.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- CSS Grid Layout Module Level 2: subgrids (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: the subgrid keyword in track listings (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: subgrid items (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: subgrid size contributions (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: gutters (W3C CSS Working Group)
- Subgrid (MDN Web Docs) (Mozilla)
- Web features explorer: Subgrid (W3C WebDX Community Group)
- HTML Standard: the fieldset and legend elements (WHATWG)
- WAI-ARIA 1.2: the group role (W3C Web Accessibility Initiative)
- CSS Conditional Rules Module Level 3: feature queries (W3C CSS Working Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress