A normal nested grid knows nothing about its parent. Put three cards in a row and each card sizes its own title, text and button, so a two-line title in one card pushes everything below it out of line with its neighbours. CSS subgrid fixes that: a grid item with grid-template-rows: subgrid or grid-template-columns: subgrid stops defining its own tracks and lays its children out on the tracks of the parent grid instead.
Because every card now shares the same rows, the tallest title in a row sets the height of that row for all of them. Content inside one component can line up with content inside another without fixed heights, JavaScript measuring or flattening your markup.
The eight demos below cover the cases people search for most: card grids, pricing tables, form labels, full-bleed article sections with named lines, product lists, a schedule that uses subgrid in both directions, a with-and-without comparison and a magazine page on a 12-column grid. Each one keeps a usable layout in browsers without subgrid and tells you when it is showing that fallback.
How subgrid works
Subgrid is part of CSS Grid Layout Module Level 2. You use it on an element that is already a grid item: give it display: grid, make it span some tracks of its parent with grid-row or grid-column, then set grid-template-rows: subgrid, grid-template-columns: subgrid, or both. In a subgridded direction the element has no tracks of its own; its children are placed on the parent's tracks, and their content counts when the parent sizes those tracks.
The span matters. A card with grid-template-rows: subgrid but no grid-row: span 4 only covers one parent row, and everything inside it lands in that one row. A subgrid cannot create implicit tracks in the subgridded direction either: if you place more items than it spans, the extra items are stacked into its last track.
Three details from the specification come up in every demo. Line numbers restart inside a subgrid, so line 1 is the subgrid's first line wherever it sits on the page. Named lines from the parent are passed down, and the subgrid can add names of its own. The parent's gap is inherited, but you can override it with gap, row-gap or column-gap on the subgrid.
Align card titles, text and buttons across a row
This is the most common reason to reach for subgrid. The parent .cards grid only defines columns, and each .card takes five of its rows with grid-row: span 5 and uses them through grid-template-rows: subgrid. Tag, title, description, meta line and button each sit in their own shared row, so a long title in the middle card pushes the description down in all three.
The parent uses gap: 1.25rem to separate cards. Inside a card that much space between the title and the text looks loose, so the card sets row-gap: .5rem. A subgrid's own gap replaces the inherited one, while the space between cards that wrap onto a new line stays at 1.25rem.
The fallback is a grid per card with grid-template-rows: auto auto 1fr auto auto. Titles and text no longer line up, but the 1fr row still pushes every button to the bottom of its card, which is what most designs needed before subgrid.
Show the code
<div class="cards">
<article class="card">
<span class="card__tag">Woodwork</span>
<h2>Intro to woodturning</h2>
<p>Turn a small bowl on the lathe and learn to sharpen a gouge safely.</p>
<span class="card__meta">9 Nov · 3 hrs · €45</span>
<a class="card__cta" href="#woodturning">Book a seat<span class="vh"> for Intro to woodturning</span></a>
</article>
<article class="card">
<span class="card__tag">Electronics</span>
<h2>Solder your first synthesizer kit and make it sing</h2>
<p>Build a two-oscillator synth from a through-hole kit. Irons, solder and fume extraction are provided, and the finished synth goes home with you.</p>
<span class="card__meta">10 Nov · 4 hrs · €70</span>
<a class="card__cta" href="#synth">Book a seat<span class="vh"> for the synthesizer kit workshop</span></a>
</article>
<article class="card">
<span class="card__tag">Print</span>
<h2>Screen printing tote bags</h2>
<p>Coat a screen, expose your own design and print three bags with water-based inks.</p>
<span class="card__meta">16 Nov · 3 hrs · €40</span>
<a class="card__cta" href="#screen-printing">Book a seat<span class="vh"> for Screen printing tote bags</span></a>
</article>
</div>.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12.5rem), 1fr));
gap: 1.25rem;
}
.card {
/* Fallback: every card is its own grid, the text row absorbs extra
height so the buttons still line up along the bottom. */
display: grid;
grid-template-rows: auto auto 1fr auto auto;
gap: .5rem;
padding: 1.25rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
& .card__tag {
justify-self: start;
padding: 2px 10px;
border-radius: 999px;
background: #e8ebfa;
color: #27348f;
font-size: .8rem;
font-weight: 600;
}
& h2 { margin: 0; font-size: 1.08rem; line-height: 1.3; }
& p { margin: 0; color: var(--muted); }
& .card__meta { font-size: .875rem; font-weight: 600; }
& .card__cta {
justify-self: start;
margin-top: .25rem;
padding: .5rem 1rem;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 600;
text-decoration: none;
}
& .card__cta:hover { background: #26368f; }
}
@supports (grid-template-columns: subgrid) {
.card {
grid-row: span 5; /* claim five rows of the parent grid */
grid-template-rows: subgrid; /* and put tag, title, text, meta, button in them */
row-gap: .5rem; /* override the 1.25rem gap inherited from .cards */
}
}Pricing table rows that line up across plans
Pricing cards are hard to align because they rarely have equal content: the featured plan carries a badge, one plan has a longer description and the feature lists differ in length. Each .plan spans five rows (heading, price, description, features, button) and subgrids them, so all three prices sit on one line and the feature lists start at the same height.
The buttons use align-self: end. Without it they would stretch to the full height of the button row, which grows when any plan's button text wraps.
Each plan stays a separate article with its own heading, so a screen reader user moves through the plans one at a time instead of reading across a table. In browsers without subgrid the feature list row is 1fr, which keeps the buttons aligned along the bottom.
Show the code
<div class="plans">
<article class="plan">
<div class="plan__head"><h2>Solo</h2></div>
<p class="plan__price"><span class="plan__amount">€0</span> free for one person</p>
<p class="plan__blurb">For freelancers answering customer email on their own.</p>
<ul>
<li>1 shared inbox</li>
<li>Saved replies</li>
<li>30 days of history</li>
</ul>
<a class="plan__cta" href="#solo">Start free<span class="vh"> on the Solo plan</span></a>
</article>
<article class="plan plan--featured">
<div class="plan__head"><h2>Team</h2><span class="plan__badge">Most popular</span></div>
<p class="plan__price"><span class="plan__amount">€12</span> per user, per month</p>
<p class="plan__blurb">For support teams of 2 to 25 who need to share the load.</p>
<ul>
<li>5 shared inboxes</li>
<li>Assignment and collision alerts</li>
<li>Internal notes on any thread</li>
<li>Unlimited history</li>
<li>Slack integration</li>
</ul>
<a class="plan__cta" href="#team">Start a trial<span class="vh"> of the Team plan</span></a>
</article>
<article class="plan">
<div class="plan__head"><h2>Business</h2></div>
<p class="plan__price"><span class="plan__amount">€29</span> per user, per month</p>
<p class="plan__blurb">Everything in Team, plus controls for larger support desks with service-level targets and audits.</p>
<ul>
<li>Unlimited inboxes</li>
<li>SLA timers and reports</li>
<li>Single sign-on (SAML)</li>
<li>Audit log export</li>
</ul>
<a class="plan__cta" href="#business">Talk to sales<span class="vh"> about the Business plan</span></a>
</article>
</div>.plans {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
gap: 1rem;
}
.plan {
/* Fallback: a grid per card; the feature list stretches so the
buttons sit on one line even when prices do not. */
display: grid;
grid-template-rows: auto auto auto 1fr auto;
row-gap: .75rem;
padding: 1.25rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
&.plan--featured { border: 2px solid var(--accent); }
& .plan__head { display: flex; flex-direction: column; align-items: flex-start; gap: .35rem; }
& h2 { margin: 0; font-size: 1.1rem; }
& .plan__badge { padding: 2px 8px; border-radius: 999px; background: var(--accent); color: #fff; font-size: .75rem; font-weight: 700; }
& .plan__price { margin: 0; color: var(--muted); font-size: .85rem; }
& .plan__amount { display: block; color: var(--ink); font-size: 2rem; font-weight: 800; line-height: 1.1; }
& .plan__blurb { margin: 0; color: var(--muted); font-size: .9rem; }
& ul { margin: 0; padding: .75rem 0 0; border-top: 1px solid var(--line); list-style: none; font-size: .9rem; }
& li { padding-left: 1.4rem; position: relative; }
& li + li { margin-top: .35rem; }
& li::before { content: "✓"; position: absolute; left: 0; color: var(--accent); font-weight: 700; }
& .plan__cta {
padding: .6rem 1rem;
border: 2px solid var(--accent);
border-radius: 10px;
color: var(--accent);
font-weight: 700;
text-align: center;
text-decoration: none;
align-self: end;
}
&.plan--featured .plan__cta { background: var(--accent); color: #fff; }
}
@supports (grid-template-columns: subgrid) {
.plan {
grid-row: span 5; /* head, price, blurb, features, button */
grid-template-rows: subgrid; /* every plan shares the same five rows */
}
}Form labels aligned across separate groups
Grouping fields into sections usually breaks label alignment: each group sizes its label column to its own longest label, so the inputs in Contact start further left than the inputs in Delivery. Here the form defines two named columns, [label] max-content and [field] minmax(0, 1fr), and each group uses grid-template-columns: subgrid. The label column is now as wide as the longest label in the whole form.
Because line names pass into subgrids, labels use grid-column: label and inputs use grid-column: field. On narrow screens the media query redefines the form as a single column with both names on the same lines, [label field] minmax(0, 1fr), and the labels drop above their inputs without any change to the markup.
The groups are div elements with role="group" and aria-labelledby pointing at their heading. In our testing, Chromium 151 did not apply subgrid to a fieldset element, while Firefox and Safari did, so test before relying on fieldset as a subgrid.
Show the code
<form class="checkout" action="#" onsubmit="return false">
<div class="group" role="group" aria-labelledby="contact-h">
<h2 id="contact-h">Contact</h2>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email">
</div>
<div class="group" role="group" aria-labelledby="delivery-h">
<h2 id="delivery-h">Delivery</h2>
<label for="street">Street address</label>
<input id="street" name="street" autocomplete="street-address">
<label for="postcode">Postcode</label>
<input id="postcode" name="postcode" autocomplete="postal-code" aria-describedby="postcode-hint">
<p class="hint" id="postcode-hint">We deliver to mainland addresses only.</p>
<label for="notes">Instructions for the driver</label>
<textarea id="notes" name="notes"></textarea>
</div>
<button type="submit">Continue to payment</button>
</form>.checkout {
display: grid;
/* Two named columns: labels size to the longest label in the whole form */
grid-template-columns: [label] max-content [field] minmax(0, 1fr);
gap: .75rem 1rem;
max-width: 40rem;
}
.checkout .group {
grid-column: 1 / -1;
/* Fallback: each group sizes its own label column */
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: .75rem 1rem;
align-items: center;
padding: .75rem 1rem 1rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
& h2 { grid-column: 1 / -1; margin: 0; font-size: 1rem; }
& label { font-weight: 600; font-size: .9rem; }
& .hint { grid-column: 2; margin: -.5rem 0 0; color: var(--muted); font-size: .8rem; }
& input, & textarea {
width: 100%;
padding: .45rem .6rem;
border: 1px solid #8a94a6;
border-radius: 8px;
font: inherit;
}
& textarea { min-height: 3.2rem; resize: vertical; }
}
.checkout button {
grid-column: field;
justify-self: start;
padding: .55rem 1.2rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
/* Narrow screens: both names point at the same single column,
so labels stack above their fields without touching the markup. */
@media (max-width: 30rem) {
.checkout { grid-template-columns: [label field] minmax(0, 1fr); }
.checkout .group { grid-template-columns: minmax(0, 1fr); row-gap: .3rem; }
.checkout .group .hint { grid-column: 1; margin: 0; }
.checkout .group label { margin-top: .4rem; }
}
@supports (grid-template-columns: subgrid) {
.checkout .group {
grid-template-columns: subgrid; /* adopt the form's [label] and [field] columns */
& label { grid-column: label; }
& input, & textarea, & .hint { grid-column: field; }
}
}Full-bleed sections with named lines
A popular article layout puts every child of the article in a grid with named lines: full-start and full-end at the edges, wide-start and wide-end a little in from them, and content-start and content-end around the text column. Writing grid-column: content uses the content-start and content-end pair, and the same goes for wide and full.
The problem is any full-bleed band with its own content. Without subgrid the band is a new grid that does not know where the article's text column is. With grid-template-columns: subgrid on the band, it keeps all five columns of the article, and its children use the same grid-column: content and grid-column: wide as the rest of the post.
The fallback uses padding-inline: max(1rem, 50% - 15rem) on the bands to centre their content. It is close, but it is a copy of the column maths rather than the grid itself, so it drifts as soon as the column widths change.
Show the code
<article class="post">
<p class="kicker">Gardening · 6 min read</p>
<h2>How to overwinter dahlia tubers</h2>
<p>Dahlias are tender perennials. Where the ground freezes, the tubers rot or split unless you lift them after the first frost has blackened the leaves and store them somewhere cool and dry.</p>
<section class="band full" aria-labelledby="glance">
<h3 id="glance">At a glance</h3>
<ul class="facts">
<li><strong>Lift</strong> one to two weeks after the first frost</li>
<li><strong>Store</strong> at 4–8 °C in dry compost or vermiculite</li>
<li><strong>Check</strong> monthly and cut away any soft tubers</li>
</ul>
</section>
<p>Cut the stems to about 10 cm, ease the clump out with a fork and turn it upside down for a few days so water drains from the hollow stems. Label each clump before it goes into storage; the tubers all look alike by spring.</p>
<figure class="band full">
<svg class="photo" viewBox="0 0 700 180" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Dahlia tubers resting in a wooden crate of dry compost">
<rect width="700" height="180" fill="#cdbb9f"/>
<rect x="120" y="60" width="460" height="110" rx="6" fill="#8b5e34"/>
<rect x="120" y="60" width="460" height="16" fill="#a8743f"/>
<ellipse cx="230" cy="80" rx="70" ry="22" fill="#6b4a2b"/>
<ellipse cx="350" cy="78" rx="62" ry="20" fill="#5c3f24"/>
<ellipse cx="465" cy="82" rx="68" ry="21" fill="#6b4a2b"/>
<rect x="225" y="40" width="10" height="34" fill="#4d6b3c"/>
<rect x="345" y="36" width="10" height="36" fill="#4d6b3c"/>
<rect x="460" y="42" width="10" height="34" fill="#4d6b3c"/>
</svg>
<figcaption>The stem stubs stay on the clump: next year's shoots grow from the crown where stem meets tuber.</figcaption>
</figure>
<p>In March, pot the clumps up in a frost-free greenhouse or a bright windowsill and plant them out once there is no more frost in the forecast.</p>
</article>.post {
display: grid;
grid-template-columns:
[full-start] minmax(1rem, 1fr)
[wide-start] minmax(0, 4rem)
[content-start] min(100% - 2rem, 30rem) [content-end]
minmax(0, 4rem) [wide-end]
minmax(1rem, 1fr) [full-end];
row-gap: 1rem;
& > * { grid-column: content; margin: 0; }
& > .full { grid-column: full; }
& .kicker { margin-top: 1.25rem; color: var(--accent); font: 700 .8rem/1.4 system-ui, sans-serif; letter-spacing: .06em; text-transform: uppercase; }
& h2 { font-size: 1.6rem; line-height: 1.2; }
& a { color: var(--accent); }
}
/* Full-bleed bands. Fallback: padding keeps the text roughly on the
article column, but it is not tied to the grid. */
.band {
padding: 1.25rem max(1rem, 50% - 15rem);
background: var(--band);
& h3 { margin: 0 0 .5rem; font: 700 1rem/1.4 system-ui, sans-serif; }
& .facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: .75rem; margin: 0; padding: 0; list-style: none; font: .9rem/1.45 system-ui, sans-serif; }
& .facts strong { display: block; font-size: 1.05rem; }
}
.photo { display: block; width: 100%; height: auto; max-height: 11rem; }
figcaption { color: var(--muted); font: italic .85rem/1.5 Georgia, serif; }
figure.band { margin: 0; padding-top: 0; background: #e4dccf; }
@supports (grid-template-columns: subgrid) {
.band {
display: grid;
grid-template-columns: subgrid; /* inherits full, wide and content lines */
row-gap: .5rem;
padding-inline: 0;
& > * { grid-column: content; } /* same line names as the article */
& .facts { grid-column: wide; } /* a little wider than the text */
& .photo { grid-column: full; }
}
}Product list with aligned ratings and prices
Subgrid works on columns too. The list defines five columns, thumbnail, details, rating, price and action, and each list item spans all of them with grid-template-columns: subgrid. Every rating and every price now sit in one column, even though each product is still a separate list item with its own background and border.
A subgrid's padding and border are added to the items in its first and last tracks. With a fixed 3rem thumbnail track and .75rem of padding the thumbnail would no longer fit, so the edge tracks use max-content and grow to make room.
Below 34rem the parent switches to three columns and the rating and button move to a second line. The subgrid rule comes after the media query in the stylesheet, so it still applies at that width.
Show the code
<ul class="products">
<li class="product">
<svg class="thumb" viewBox="0 0 48 48" aria-hidden="true"><rect width="48" height="48" fill="#f2e3d5"/><rect x="17" y="14" width="14" height="24" rx="3" fill="#6b4226"/><rect x="22" y="8" width="16" height="4" rx="2" fill="#3f2a1a"/></svg>
<div class="info"><h2>Hand grinder</h2><p>Steel burrs, 40 settings</p></div>
<span class="rating"><b>4.7</b> ★ <span class="vh">out of 5,</span> (312 reviews)</span>
<span class="price">€89.00</span>
<button type="button">Add<span class="vh"> Hand grinder</span> to cart</button>
</li>
<li class="product">
<svg class="thumb" viewBox="0 0 48 48" aria-hidden="true"><rect width="48" height="48" fill="#e3ebf2"/><path d="M12 36h20V20H12z" fill="#2f3e4e"/><path d="M32 22c6 0 8-6 10-10" stroke="#2f3e4e" stroke-width="3" fill="none"/></svg>
<div class="info"><h2>Gooseneck kettle with temperature hold, 1 litre</h2><p>Brushed stainless steel</p></div>
<span class="rating"><b>4.5</b> ★ <span class="vh">out of 5,</span> (1,084 reviews)</span>
<span class="price">€129.00</span>
<button type="button">Add<span class="vh"> Gooseneck kettle</span> to cart</button>
</li>
<li class="product">
<svg class="thumb" viewBox="0 0 48 48" aria-hidden="true"><rect width="48" height="48" fill="#f5efe3"/><path d="M12 14h24l-12 22z" fill="#d9c7a5"/></svg>
<div class="info"><h2>Paper filters</h2><p>Size 02, pack of 100</p></div>
<span class="rating"><b>4.9</b> ★ <span class="vh">out of 5,</span> (58 reviews)</span>
<span class="price">€6.50</span>
<button type="button">Add<span class="vh"> Paper filters</span> to cart</button>
</li>
<li class="product">
<svg class="thumb" viewBox="0 0 48 48" aria-hidden="true"><rect width="48" height="48" fill="#e6f0ea"/><path d="M16 12h16l-2 8c6 4 6 16-6 16s-12-12-6-16z" fill="#9bc1ad"/><rect x="18" y="17" width="12" height="4" fill="#7a5230"/></svg>
<div class="info"><h2>Glass carafe</h2><p>600 ml, oak collar</p></div>
<span class="rating"><b>4.2</b> ★ <span class="vh">out of 5,</span> (97 reviews)</span>
<span class="price">€34.00</span>
<button type="button">Add<span class="vh"> Glass carafe</span> to cart</button>
</li>
</ul>.products {
display: grid;
/* Edge tracks are auto-sized: a subgrid's padding is added to the
items in its first and last tracks, so fixed edge tracks would clip. */
grid-template-columns:
[thumb] max-content [info] minmax(0, 1fr) [rating] max-content
[price] max-content [action] max-content;
gap: .6rem;
margin: 0;
padding: 0;
list-style: none;
}
.product {
grid-column: 1 / -1;
/* Fallback: each row sizes its own columns, so the rating and price
columns move left or right with the length of the product name. */
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) max-content max-content max-content;
column-gap: 1rem;
align-items: center;
padding: .6rem .75rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
& .thumb { width: 3rem; height: 3rem; border-radius: 10px; }
& h2 { margin: 0; font-size: .95rem; }
& .info p { margin: 0; color: var(--muted); font-size: .8rem; }
& .rating { color: var(--muted); font-size: .85rem; white-space: nowrap; }
& .rating b { color: var(--ink); }
& .price { font-weight: 800; font-variant-numeric: tabular-nums; text-align: right; }
& button {
padding: .4rem .8rem;
border: 2px solid var(--accent);
border-radius: 999px;
background: #fff;
color: var(--accent);
font: inherit;
font-size: .85rem;
font-weight: 700;
cursor: pointer;
}
& button:hover { background: var(--accent); color: #fff; }
}
/* Narrow: three columns, rating and button drop to a second line.
The subgrid block comes after this so it still wins when supported. */
@media (max-width: 34rem) {
.products { grid-template-columns: [thumb] max-content [info rating] minmax(0, 1fr) [price action] max-content; }
.product { grid-template-columns: 3rem minmax(0, 1fr) max-content; row-gap: .35rem; }
.product .thumb { grid-row: span 2; align-self: start; }
.product .rating { grid-column: 2; }
.product .price, .product button { grid-column: 3; }
.product .price { grid-row: 1; }
}
@supports (grid-template-columns: subgrid) {
.product {
grid-template-columns: subgrid; /* one set of columns for every row */
column-gap: 1rem; /* override the list's .6rem gap */
}
}Subgrid in both directions: a conference schedule
The schedule grid names its columns time, a, b and c, and its rows head, t0900, t1000, t1100, t1200 and close. Sessions are placed with grid-area using those names. The workshop spans halls B and C from t1000 to close and sets both grid-template-columns: subgrid and grid-template-rows: subgrid, so its group sessions line up with the 11:00 and 12:00 rows and with the hall columns.
Inside the workshop, line numbers restart: its intro uses grid-column: 1 / -1 and spans the workshop's two columns, not the whole schedule. It also sets gap: .25rem, tighter than the schedule's .5rem, which shows how a subgrid can keep the parent's tracks and still use its own gutters.
The header row is a subgrid in columns only. In browsers without subgrid it falls back to display: contents, which lifts the hall names into the schedule grid so they still sit above their columns. That fallback is fine here because the header wrapper has no role of its own.
Show the code
<div class="schedule">
<div class="head" style="grid-row: head">
<span><span class="vh">Time</span></span><span>Hall A</span><span>Hall B</span><span>Hall C</span>
</div>
<p class="time" style="grid-row: t0900">09:00</p>
<article class="session" style="grid-area: t0900 / a / t1000 / end">
<h2>Opening keynote: The web we keep</h2><p>Priya Raman · all halls</p>
</article>
<p class="time" style="grid-row: t1000">10:00</p>
<article class="session" style="grid-area: t1000 / a">
<h2>Container queries in production</h2><p>Maya Lindqvist</p>
</article>
<section class="workshop" style="grid-area: t1000 / b / close / end" aria-labelledby="ws">
<div><h2 id="ws">Workshop: accessible forms</h2><p>10:00 intro in Hall B, then two groups swap halls</p></div>
<div><h3>Group 1: labels and errors</h3><p>11:00 · Hall B</p></div>
<div><h3>Group 2: screen reader testing</h3><p>11:00 · Hall C</p></div>
<div><h3>Group 2: labels and errors</h3><p>12:00 · Hall B</p></div>
<div><h3>Group 1: screen reader testing</h3><p>12:00 · Hall C</p></div>
</section>
<p class="time" style="grid-row: t1100">11:00</p>
<article class="session" style="grid-area: t1100 / a">
<h2>Shipping less JavaScript</h2><p>Tomás Ortega</p>
</article>
<p class="time" style="grid-row: t1200">12:00</p>
<article class="session" style="grid-area: t1200 / a">
<h2>Design tokens without a build step</h2><p>Aiko Mori</p>
</article>
</div>.schedule {
display: grid;
grid-template-columns: [time] max-content [a] minmax(0, 1fr) [b] minmax(0, 1fr) [c] minmax(0, 1fr) [end];
grid-template-rows: [head] auto [t0900] auto [t1000] auto [t1100] auto [t1200] auto [close];
gap: .5rem;
& .time { grid-column: time; margin: 0; padding-top: .45rem; color: var(--muted); font-weight: 700; font-variant-numeric: tabular-nums; }
& .session, & .workshop > * { padding: .45rem .55rem; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
& h2, & h3 { margin: 0; font-size: .9em; line-height: 1.3; }
& p { margin: .15rem 0 0; color: var(--muted); font-size: .85em; }
}
/* Header row. Fallback: display: contents lifts the hall names into
the schedule grid so they still sit above their columns. */
.head { display: contents; font-weight: 700; }
.head span { padding: .3rem .55rem; border-bottom: 3px solid var(--accent); }
.workshop {
/* Fallback: its own two-column grid, rows not tied to the times */
display: grid;
grid-template-columns: 1fr 1fr;
gap: .25rem;
padding: .3rem;
background: var(--tint);
border-radius: 10px;
& > :first-child { grid-column: 1 / -1; background: transparent; border: 0; }
}
@supports (grid-template-columns: subgrid) {
.head {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid; /* columns only */
background: var(--surface);
border-radius: 8px;
}
.workshop {
grid-template-columns: subgrid; /* halls B and C */
grid-template-rows: subgrid; /* 10:00, 11:00 and 12:00 */
gap: .25rem; /* tighter than the parent's .5rem */
}
}Subgrid on and off: the problem it solves
This demo is the same card pattern with a switch. The dashed outlines show each card's rows. With subgrid on, every tag, title, excerpt and byline sits in a row shared by all three cards. Switch it off and each card sizes its own rows, so the excerpts start at different heights depending on how long each title is.
The switch is a button with aria-pressed, and it adds or removes an is-off class on the grid. The CSS limits subgrid to .posts:not(.is-off), inside @supports. The script checks CSS.supports('grid-template-columns', 'subgrid') first and disables the switch when there is nothing to compare.
Show the code
<div class="posts">
<article class="post">
<span class="post__tag">Performance</span>
<h2>Why our LCP got worse after we lazy-loaded the hero image</h2>
<p>The browser waited for layout before fetching the one image that mattered.</p>
<span class="post__by">Jonas Weber · 5 min</span>
</article>
<article class="post">
<span class="post__tag">CSS</span>
<h2>Fluid type with clamp()</h2>
<p>One line per heading level replaces a stack of media queries, and still respects the user's zoom setting when you mix rem into the middle value.</p>
<span class="post__by">Grace Okafor · 7 min</span>
</article>
<article class="post">
<span class="post__tag">Accessibility</span>
<h2>Focus styles people can see</h2>
<p>Three outline patterns that hold up on light and dark backgrounds.</p>
<span class="post__by">Lena Novak · 4 min</span>
</article>
</div>.posts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
gap: 1rem;
}
.post {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: .5rem;
padding: 1rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
/* dashed outlines make each row visible */
& > * { margin: 0; outline: 1px dashed color-mix(in srgb, var(--guide) 45%, transparent); outline-offset: 2px; }
& .post__tag { color: var(--accent); font-size: .78rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
& h2 { font-size: 1.02rem; line-height: 1.3; }
& p { color: var(--muted); font-size: .9rem; }
& .post__by { font-size: .82rem; font-weight: 600; }
}
@supports (grid-template-columns: subgrid) {
.posts:not(.is-off) .post {
grid-row: span 4;
grid-template-rows: subgrid;
}
}function initSubgridToggle() {
const button = document.querySelector('[data-subgrid-toggle]');
const grid = document.querySelector('.posts');
const status = document.getElementById('status');
if (!button || !grid) return;
if (!CSS.supports('grid-template-columns', 'subgrid')) {
button.disabled = true;
button.setAttribute('aria-pressed', 'false');
status.textContent = 'Subgrid is not supported here, so every card is its own grid.';
return;
}
button.addEventListener('click', () => {
const on = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(on));
grid.classList.toggle('is-off', !on);
status.textContent = on
? 'On: tags, titles, excerpts and bylines share rows.'
: 'Off: each card sizes its own rows, so titles and bylines drift.';
});
}
initSubgridToggle();Magazine layout: adding line names to a subgrid
Editorial pages often use a 12-column grid so that blocks further down the page line up with blocks above them. The lead story takes seven columns and the brief list takes five. The food section spans all twelve with grid-template-columns: subgrid, so its stories sit on the same column lines as the top of the page, and its second story starts on the same line as the brief list.
Counting columns gets hard to read, so the section adds its own names after the subgrid keyword: subgrid [intro-start] repeat(2, []) [intro-end main-start] repeat(3, []) [main-end side-start] repeat(4, []) [side-end]. Each pair of brackets names one line of the subgrid, in order. The stories then use grid-column: main and grid-column: side.
The stories also subgrid the section's rows, so kicker, headline and standfirst line up across them. The section is a subgrid in columns but defines its own rows, which shows that the two directions are independent. Without subgrid, the section falls back to three equal columns of its own: still tidy, just not tied to the page grid.
Show the code
<div class="mag">
<article class="lead">
<div class="art" aria-hidden="true"></div>
<p class="kicker">Boatbuilding</p>
<h2><a href="#boatyard">Inside the last wooden boatyard on the estuary</a></h2>
<p class="standfirst">Three shipwrights, one steam box and a waiting list that runs to 2028.</p>
</article>
<aside class="briefs" aria-labelledby="brief-h">
<h2 id="brief-h">In brief</h2>
<ol>
<li><a href="#ferry">Ferry timetable changes from Monday</a></li>
<li><a href="#wall">Harbour wall repairs finish a month early</a></li>
<li><a href="#market">Fish market moves to Saturday mornings</a></li>
</ol>
</aside>
<section class="section" aria-labelledby="food-h">
<div class="section__intro">
<h2 id="food-h">Food and drink</h2>
<p class="standfirst">Where to eat on the waterfront this month.</p>
</div>
<article class="story">
<p class="kicker">Review</p>
<h3><a href="#oysters">The oyster shed that only opens at low tide</a></h3>
<p class="standfirst">Six stools, two kinds of oyster and a tide table taped to the door.</p>
</article>
<article class="story story--side">
<p class="kicker">Recipe</p>
<h3><a href="#mackerel">Smoked mackerel pâté</a></h3>
<p class="standfirst">Ten minutes, one bowl, and it keeps for three days in the fridge.</p>
</article>
</section>
</div>.mag {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 1.25rem 1rem;
& h2, & h3, & p { margin: 0; }
& a { color: inherit; text-decoration-color: var(--accent); text-underline-offset: 3px; }
& a:hover { color: var(--accent); }
& .kicker { color: var(--accent); font-size: .75rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
& .standfirst { color: var(--muted); font-size: .9rem; }
}
.lead {
grid-column: 1 / span 7;
display: grid;
gap: .4rem;
align-content: start;
& .art { height: 7.5rem; border-radius: 6px; background: linear-gradient(160deg, #a9c5d6 0 45%, #4f7b94 45% 62%, #7a5232 62%); }
& h2 { font: 700 1.45rem/1.15 Georgia, "Times New Roman", serif; }
}
.briefs {
grid-column: 8 / -1;
padding-left: 1rem;
border-left: 1px solid var(--line);
& h2 { margin-bottom: .4rem; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; }
& ol { margin: 0; padding: 0; list-style: none; font-size: .9rem; font-weight: 600; line-height: 1.35; }
& li + li { margin-top: .6rem; padding-top: .6rem; border-top: 1px solid var(--line); }
}
.section {
grid-column: 1 / -1;
/* Fallback: three equal columns of its own */
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .3rem 1rem;
padding-top: .75rem;
border-top: 2px solid var(--ink);
& .section__intro h2 { font: 700 1.15rem/1.2 Georgia, "Times New Roman", serif; }
& .story { display: grid; gap: .3rem; align-content: start; }
& .story h3 { font-size: 1rem; line-height: 1.3; }
}
@supports (grid-template-columns: subgrid) {
.section {
/* Use the page's 12 columns and add our own line names on top */
grid-template-columns:
subgrid [intro-start] repeat(2, []) [intro-end main-start] repeat(3, [])
[main-end side-start] repeat(4, []) [side-end];
grid-template-rows: repeat(3, auto);
& .section__intro { grid-column: intro; grid-row: span 3; }
& .story {
grid-column: main;
grid-row: span 3;
grid-template-rows: subgrid; /* kicker, headline, standfirst line up */
}
& .story--side {
grid-column: side; /* starts on the same line as .briefs above */
padding-left: 1rem;
border-left: 1px solid var(--line);
}
}
}
@media (max-width: 34rem) {
.lead, .briefs, .section, .section > * { grid-column: 1 / -1; }
.briefs { padding: .75rem 0 0; border-left: 0; border-top: 1px solid var(--line); }
.section { grid-template-columns: minmax(0, 1fr); row-gap: .75rem; }
@supports (grid-template-columns: subgrid) {
.section { grid-template-columns: subgrid; row-gap: .3rem; }
.section .section__intro, .section .story { grid-column: 1 / -1; }
.section .story--side { padding-left: 0; border-left: 0; }
.section .section__intro { margin-bottom: .5rem; }
}
}CSS nesting in these demos
The demo CSS uses native CSS nesting to keep each component's rules together: a rule such as .card { & h2 { … } } is the same as writing .card h2 { … }. Nesting reached Baseline in December 2023. Chrome 112 and Safari 16.5 shipped an early version that needed a symbol such as & in front of a nested element selector; the relaxed syntax that allows a bare h2 arrived in Chrome 120, Safari 17.2 and Firefox 117.
Every nested selector here starts with &, so it works in both versions. Nested rules come after the plain declarations in each block, because a browser without nesting skips a nested rule as an invalid declaration and can take the next declaration with it.
Firefox 71 to 116 support subgrid but not nesting. If you need those versions, flatten the snippets: replace each & with the parent selector and move the rule outside the block.
Fallbacks and progressive enhancement
A browser that does not understand subgrid ignores the whole grid-template-rows or grid-template-columns declaration. The element is then laid out with whatever else applies to it, so write the fallback first: a normal nested grid, or flexbox, that looks acceptable on its own. Then add subgrid inside @supports (grid-template-columns: subgrid), together with the grid-row or grid-column span that only makes sense with it.
Keep the span in the same @supports block as the subgrid value. The span only exists to give the subgrid tracks to use; without subgrid it just adds rows or columns to the parent that nothing else needs.
Watch named lines too. If an item uses grid-column: field and the grid it lands in has no line called field, the browser creates implicit lines and the layout breaks. That is why the schedule's workshop places its sessions by source order rather than by the parent's line names.
Every demo here shows a short note when subgrid is missing. The note is visible by default and hidden by @supports, so it needs no JavaScript.
Accessibility
Subgrid changes where boxes are drawn, not the order they are read in. Keyboard focus and screen readers follow the source, so write the HTML in a sensible reading order first; none of these demos reorders content visually.
Subgrid also removes a reason to use display: contents. Flattening a card with display: contents so its parts join the parent grid removes the card's own box, and older browser versions removed the element's role from the accessibility tree as well. With subgrid the article, list item or group keeps its box, semantics and heading.
Links and buttons that repeat the same visible text, such as Book a seat or Add to cart, include visually hidden text naming the item, so each one makes sense on its own in a list of links or controls.
Frequently asked questions
What is CSS subgrid?
Subgrid is a value for grid-template-columns and grid-template-rows. A nested grid that uses it does not define its own tracks in that direction; its children are placed on the tracks of the parent grid. It lets content inside separate components, such as cards, line up with each other.
Which browsers support subgrid?
Firefox 71, Safari 16, and Chrome and Edge 117 and later. Support was complete across those browsers in September 2023, which makes subgrid part of Baseline 2023, and it is now Baseline widely available. Older versions ignore the subgrid value, so use @supports to add it on top of a fallback.
How do I align card content across a row with subgrid?
Make the card container a grid with columns only. On each card, set display: grid, grid-row: span N where N is the number of parts in the card, and grid-template-rows: subgrid. Each part then sits in a row shared with the matching part of the other cards in that row.
What happens in browsers that do not support subgrid?
They drop the subgrid declaration and lay the element out as an ordinary nested grid or block, with its own tracks. Content will not line up across components, but it stays readable if the fallback styles are reasonable. Keep the subgrid value and the grid-row or grid-column span together inside an @supports block.
Does a subgrid inherit the gap and line names of its parent?
Yes. The parent's gap and named lines pass into the subgrid. You can set a different gap on the subgrid to change the spacing between its items, and add more line names after the subgrid keyword, for example subgrid [intro-start] [intro-end]. Line numbers restart at 1 inside the subgrid.
More from the Modern CSS lab
- CSS :has() Selector: 8 Practical Examples of the Parent Selector
- CSS Container Queries: 8 Practical @container Examples
- CSS Anchor Positioning: 8 Practical Examples
- HTML Popover API: 8 Practical Examples
- CSS Scroll-Driven Animations: 8 Examples with animation-timeline
- CSS View Transitions: 8 Live Examples of the View Transition API
- Animating height: auto and display: none in CSS