CSS Grid
Two-dimensional layout with CSS Grid: defining rows and columns, the fr unit, placing items by line and by area name, repeat and minmax, auto-fit, and subgrid.
What CSS Grid is
CSS Grid (officially the CSS Grid
Layout Module) is a layout system that arranges the children of an
element in rows and columns at the same time. You switch it on with
display: grid. The element becomes a
grid container, and each of its direct children
becomes a grid item. Grandchildren are not
affected: they lay out normally inside their grid item.
Flexbox (see Flexbox) is one-dimensional: it puts items in a row or a column, and each line of a wrapped flex container sizes itself on its own. A grid controls both directions at once, so items in different rows still line up in the same columns. That makes it the natural tool for page layouts, dashboards, card collections and forms with aligned labels. Grid has worked in all major browsers since 2017.
The Kestrel Energy outage page shows six figures as tiles. Three equal columns and a gap are all it takes:
<div class="tiles">
<div>Open outages: 3</div>
<div>Customers affected: 1,240</div>
<div>Crews on site: 5</div>
<div>Average restore time: 2 h 10 min</div>
<div>Calls waiting: 18</div>
<div>Last update: 14:05</div>
</div>
.tiles {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
}
.tiles > div {
border: 1px solid #555;
padding: 0.75rem;
}
There are six items and three columns, so the grid creates two rows by itself. The tiles in each row get the height of the tallest one, and the columns stay the same width even when one tile has more text.
Rows, columns and tracks
A grid track is one row or one
column of the grid: the space between two neighboring grid lines.
You define column tracks with grid-template-columns and
row tracks with grid-template-rows. Each value in the
list is one track, so
grid-template-columns: 8rem 1fr creates two columns: a
fixed one of 8rem and a flexible one that takes the rest. A single
grid cell is where one row and one column cross.
The space between tracks is set with gap (or
row-gap and column-gap separately), the
same property flexbox uses. Gaps only appear
between tracks, never at the outer edges, so you do not
need margin tricks. Older code writes grid-gap, the
original name, which browsers still accept as an alias.
The explicit and the implicit grid
The tracks you define yourself form the
explicit grid. When there are more items than
cells, the browser adds tracks automatically: the
implicit grid. By default extra items go into new
rows, and those rows are sized by
grid-auto-rows (default auto: as tall as
their content). This is why you often define only the columns and
let the rows appear as needed.
A description list of meter details is a good fit: every
<dt> goes into the first column, every
<dd> into the second, and the rows are implicit.
<dl class="meter">
<dt>Meter ID</dt>
<dd>E-0042-7781</dd>
<dt>Address</dt>
<dd>Laan van Meerdervoort 12, Den Haag</dd>
<dt>Last reading</dt>
<dd>18,412 kWh on 1 September 2026</dd>
<dt>Next reading due</dt>
<dd>1 December 2026</dd>
</dl>
.meter {
display: grid;
grid-template-columns: 9rem 1fr;
gap: 0.25rem 1rem; /* row gap, column gap */
}
.meter dt {
font-weight: bold;
}
.meter dd {
margin: 0; /* remove the default indent */
}
Because the labels share one column, they line up without fixed widths on each label, and a long address simply wraps inside its cell.
The fr unit
The fr unit (fraction)
represents a share of the free space in the grid container. The
browser first gives fixed tracks (px, rem,
percentages) their size and subtracts the gaps; whatever is left is
divided between the fr tracks in proportion to their
numbers.
<div class="row">
<div>150px</div>
<div>1fr</div>
<div>2fr</div>
</div>
.row {
display: grid;
grid-template-columns: 150px 1fr 2fr;
gap: 0.5rem;
}
.row > div {
background: #e3ecf7;
border: 1px solid #2b5a8a;
padding: 1rem 0.5rem;
text-align: center;
}
In a container 560px wide, the gaps take 16px and the first column
150px. The remaining 394px is split into three shares: the
1fr column gets one (about 131px), the
2fr column two (about 263px). Make the window narrower
and only the fr columns shrink.
Why not just use percentages? 33.33% columns plus a gap
add up to more than 100%, so the grid overflows; you would have to
subtract the gaps yourself with calc().
fr works with what is left after the gaps, so
it never overflows because of them.
Placing items by line number
A grid line is one of the dividing lines on either side of a track. Lines are numbered from 1, starting at the start edge (the left in English, the top for rows). A grid with three columns has four column lines: 1, 2, 3 and 4. Lines can also be counted from the end with negative numbers: -1 is always the last line of the explicit grid.
You place an item with grid-column and
grid-row, each written as
start / end. Instead of an end line you can write
span n: the number of tracks to cover. The Kestrel portal dashboard puts
the outage map over three columns and two rows, and a timeline over
the full width:
<div class="dashboard">
<section class="map">Outage map</section>
<section>Alerts</section>
<section>Crews</section>
<section class="timeline">Timeline</section>
</div>
.dashboard {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0.5rem;
}
.dashboard > section {
border: 1px solid #555;
padding: 0.75rem;
}
.map {
grid-column: 1 / 4; /* from line 1 to line 4: three columns */
grid-row: 1 / span 2; /* start at row line 1, cover two rows */
min-height: 8rem;
}
.timeline {
grid-column: 1 / -1; /* first line to last line: full width */
}
Alerts and Crews have no placement, so the browser's auto-placement puts them in the first free cells: column 4 of row 1 and column 4 of row 2. The timeline is placed by line and lands in row 3.
Named grid areas
A grid area is a rectangle of one
or more cells that an item occupies. You can give areas names and
draw the layout as text with grid-template-areas: each
quoted string is one row, each word one cell. Repeating a name over
several cells makes that area bigger; a period (.)
leaves a cell empty. Each item then says which area it goes into
with grid-area.
<div class="page">
<header>Kestrel Energy</header>
<nav aria-label="Main"><a href="#">Outages</a></nav>
<main>Outage map and list</main>
<aside>Planned maintenance</aside>
<footer>Contact and opening hours</footer>
</div>
.page {
display: grid;
grid-template-columns: 8rem 1fr 9rem;
grid-template-areas:
"header header header"
"nav main aside"
"footer footer footer";
gap: 0.5rem;
}
.page > * {
border: 1px solid #555;
padding: 0.5rem;
}
header { grid-area: header; }
nav { grid-area: nav; }
main { grid-area: main; min-height: 6rem; }
aside { grid-area: aside; }
footer { grid-area: footer; }
The layout is readable straight from the CSS, and changing it is a
matter of redrawing the strings. That makes named areas a good match
for responsive design: a media query can redefine
grid-template-areas while the items keep their names.
For a phone, a single column could look like this (see
Responsive Design for media
queries):
.page {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"nav"
"main"
"aside"
"footer";
}
The rules for the strings: every row needs the same number of cells,
and each name must form one rectangle. An L-shaped area makes the
whole grid-template-areas declaration invalid, and the
browser ignores it.
repeat() and minmax()
Writing 1fr 1fr 1fr 1fr gets tedious.
repeat(4, 1fr) means the same: the second argument is
repeated the given number of times. It can repeat a pattern too:
repeat(2, 10rem 1fr) produces four tracks.
minmax(min, max)
defines a track size range: the track is never smaller than
min and never larger than max.
minmax(10rem, 1fr) is a flexible column that will not
shrink below 10rem; minmax(auto, 20rem) grows with its
content up to 20rem.
Why a 1fr column sometimes refuses to shrink
A plain 1fr track is really
minmax(auto, 1fr): its minimum is the smallest size of
its content. Long content that cannot wrap, such as a log line in a
<pre>, a long URL or a wide table, then pushes
the column wider than its share and the whole grid overflows. The
fix is to allow the track to shrink below its content with
minmax(0, 1fr) and let the wide content scroll in its
own box:
<p><strong>1fr 1fr</strong></p>
<div class="ticket plain">
<div>INC-31902: portal logins fail</div>
<pre>2026-10-01T19:35:02Z auth-gateway ERROR upstream timeout after 30000 ms</pre>
</div>
<p><strong>minmax(0, 1fr) minmax(0, 1fr)</strong></p>
<div class="ticket fixed">
<div>INC-31902: portal logins fail</div>
<pre>2026-10-01T19:35:02Z auth-gateway ERROR upstream timeout after 30000 ms</pre>
</div>
.ticket {
display: grid;
gap: 0.5rem;
}
.ticket > * {
border: 1px solid #555;
padding: 0.5rem;
margin: 0;
}
.plain {
grid-template-columns: 1fr 1fr;
}
.fixed {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.fixed pre {
overflow-x: auto; /* the log line scrolls inside its own box */
}
In the first grid the log line forces its column wide and the grid sticks out of the frame (you can scroll the result sideways). In the second, both columns keep their equal share and only the log box scrolls.
auto-fit and auto-fill
Instead of a number, repeat() accepts the keywords
auto-fill and auto-fit: "as many tracks as
fit". Combined with minmax() this gives a responsive
card grid in one line, without any media query:
<ul class="tariffs">
<li><strong>Green Fixed</strong><br>Wind and solar, price fixed for 3 years.</li>
<li><strong>Green Flex</strong><br>Wind and solar, monthly price.</li>
<li><strong>Dynamic</strong><br>Hourly market price.</li>
<li><strong>Business</strong><br>For small companies.</li>
<li><strong>Heat</strong><br>District heating.</li>
</ul>
.tariffs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(10rem, 100%), 1fr));
gap: 0.75rem;
list-style: none;
margin: 0;
padding: 0;
}
.tariffs li {
border: 1px solid #555;
padding: 0.75rem;
}
Read the column definition as: "make as many columns as fit, each at
least 10rem wide, and share any leftover space equally". In the
content column of this page that is three columns; resize the window
and the grid drops to two and then one column on its own. The inner
min(10rem, 100%) prevents overflow when the container
itself is narrower than 10rem.
auto-fit versus auto-fill
Both keywords create as many tracks as fit. The difference shows
when there are fewer items than tracks.
auto-fit collapses the
empty tracks to zero, so the items stretch to fill the row.
auto-fill keeps the empty tracks, so the items keep
their share and empty space remains at the end:
<p>auto-fill</p>
<div class="cards fill"><div>Electricity</div><div>Gas</div></div>
<p>auto-fit</p>
<div class="cards fit"><div>Electricity</div><div>Gas</div></div>
.cards {
display: grid;
gap: 0.5rem;
}
.cards div {
border: 1px solid #555;
padding: 0.5rem;
}
.fill { grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); }
.fit { grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); }
With many items the two behave the same. For a card grid that should
always use the full width, use auto-fit; for a grid
where a lone card should stay card-sized, use
auto-fill.
Subgrid
A grid item can itself be a grid container, but a nested grid normally has its own tracks that know nothing about the outer grid. Cards side by side then have titles, texts and buttons at different heights, because each card sizes its rows on its own.
Subgrid solves this: with
grid-template-rows: subgrid (or
grid-template-columns: subgrid), a nested grid uses the
tracks of its parent grid instead of defining its own. Each card
below spans three rows of the outer grid and lays out its title,
text and link on those shared rows, so all titles get the height of
the longest title, and so on:
<div class="plans">
<article class="plan">
<h3>Green Fixed</h3>
<p>Wind and solar power at one price for three years.</p>
<a href="#">Choose Green Fixed</a>
</article>
<article class="plan">
<h3>Dynamic hourly tariff</h3>
<p>Pay the market price per hour. Cheaper at night and on sunny, windy days; more expensive in winter evenings.</p>
<a href="#">Choose Dynamic</a>
</article>
<article class="plan">
<h3>Heat</h3>
<p>District heating.</p>
<a href="#">Choose Heat</a>
</article>
</div>
.plans {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.plan {
display: grid;
grid-row: span 3; /* take three rows of the outer grid */
grid-template-rows: subgrid; /* and use those rows inside */
gap: 0.5rem;
border: 1px solid #555;
padding: 0.75rem;
}
.plan h3,
.plan p {
margin: 0;
}
The titles, texts and links line up across the three cards even
though their text lengths differ. Remove
grid-template-rows: subgrid and each card spaces its
own content again. A subgrid inherits the parent's gaps unless it
sets its own, as the cards do here.
Alignment, and grid versus flexbox
Grid uses the same alignment properties as flexbox.
justify-items aligns items along the row (inline)
direction inside their cells, align-items along the
column (block) direction, and justify-content /
align-content move the whole set of tracks when the
grid is smaller than its container. The shorthand
place-items: center sets both item alignments at once,
which makes it the shortest way to center something:
<div class="empty">
<p>No outages in your area.</p>
</div>
.empty {
display: grid;
place-items: center;
min-height: 8rem;
border: 1px dashed #555;
}
Choosing between grid and flexbox
| Situation | Usually the better fit |
|---|---|
| A row of buttons, a toolbar, a navigation bar, a label next to an icon | Flexbox: one direction, sizes come from the content |
| A page layout with header, sidebar, main and footer | Grid: rows and columns at once, named areas |
| A collection of cards that should form neat columns |
Grid with auto-fit and minmax()
|
| Tags or chips that wrap and keep their own widths | Flexbox with flex-wrap: wrap |
| A form with labels and fields in aligned columns | Grid |
The rule of thumb: flexbox works from the content outward (the items decide their sizes), grid works from the layout inward (the container decides the tracks and the items fill them). The two combine freely: a grid item can be a flex container and the other way around.