// web_layout · css_grid
CSS Grid Is a Two-Dimensional System, Not Flexbox With Extra Steps
Flexbox distributes space along one axis and calls it done; Grid controls rows and columns at the same time, which is a different class of problem, not a bigger version of the same one. This guide covers the track model that makes that possible — explicit vs. implicit tracks, the fr unit, named grid-template-areas, and the subgrid behavior that lets a nested layout inherit its parent's rhythm instead of rebuilding it from scratch.
§ 01
Two Axes, One Layout
Flexbox is fundamentally one-dimensional. flex-direction
picks a single main axis, every item is laid out one after
another along it, and the cross axis only gets alignment —
align-items can nudge items up, down, or center
within a line, but it never sizes or positions a second
independent axis. Wrap a flex row onto multiple lines and each
line is still solved as its own one-axis problem; nothing
connects the column edges between line one and line two, because
flexbox never modeled a shared column in the first place.
Grid removes that limit by defining the whole layout as a single
matrix up front. grid-template-columns and
grid-template-rows exist at the same time, on the
same element, and every item's position is a coordinate in that
shared 2D structure — not a point along one line. An item can
span three columns and two rows in a single placement, something
flexbox has no vocabulary for without extra wrapper elements
faking what Grid does natively.
None of this makes Flexbox obsolete — it's still the more direct tool for a genuinely one-axis problem like a toolbar or a tag list. The point is that Grid isn't Flexbox with a couple of extra properties bolted on; it's solving for two axes at once, and the rest of this guide is about the vocabulary that model needs — tracks, lines, areas, and how a nested grid can share its parent's rather than invent its own.
§ 02
Explicit Tracks: The Grid You Declare
An explicit track is one you named yourself,
with grid-template-columns or
grid-template-rows:
.layout {
display: grid;
grid-template-columns: 220px repeat(3, 1fr);
grid-template-rows: auto 1fr auto;
gap: 24px;
}
That declares a fixed 220px column followed by three equal
flexible columns, and three rows sized to content, remaining
space, and content again. repeat(3, 1fr) is just
shorthand for writing 1fr 1fr 1fr — it doesn't
change what's produced, only how it's written.
Every set of N tracks creates N + 1 grid lines — the boundaries between and around the tracks, numbered from 1 at the start edge (or -1 from the end, counting backward). A four-column grid has five column lines; placement properties in section five reference those line numbers directly, which is why it's worth thinking of a grid as a set of numbered lines first and tracks second. Lines can be named too, so placement reads by intent instead of by counting:
grid-template-columns: [sidebar-start] 220px [sidebar-end main-start] 1fr [main-end];
An item can then be placed with
grid-column: sidebar-start / sidebar-end; instead
of grid-column: 1 / 2; — functionally identical,
but the named version survives a reviewer skimming the CSS
without mentally counting lines, and survives you adding a
column later without renumbering every placement that referenced
a line by position.
§ 03
The fr Unit and minmax()
fr stands for a fraction of the leftover
space — space still unclaimed after every fixed-size
and content-sized track in the same axis has already been
resolved. grid-template-columns: 220px 1fr 2fr;
gives the first column exactly 220px, then splits whatever
width remains into three parts, handing one part to the second
column and two parts to the third. It's the same distribution
idea as flex-grow, just applied across a full track
list on either axis instead of one flex line.
minmax(min, max) gives a single track a floor and
a ceiling instead of one fixed number, and combined with
repeat() and an auto-repeat keyword, it produces a
genuinely fluid grid with zero media queries:
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
That reads as: fit as many 240px-minimum columns as the container allows, and let them stretch evenly to fill whatever space is left. The column count recalculates continuously as the container resizes — it isn't jumping between fixed states at set breakpoints.
auto-fit and auto-fill differ in one
specific, commonly-confused way: both compute the same maximum
number of tracks that could fit at the minimum size, but
auto-fill keeps every one of those tracks in the
grid — even the ones with no items in them, which sit there as
empty columns — while auto-fit collapses any empty
tracks to zero width and lets the tracks that do have content
stretch into the freed-up space. Five items in an
auto-fill grid with room for eight columns leaves
three visibly empty columns; the identical grid with
auto-fit instead stretches those five items wider
to fill the row. Neither is "correct" — a filmstrip that should
keep a consistent column width even when under-populated wants
auto-fill; a card grid that should always look full
wants auto-fit.
§ 04
Implicit Tracks: What the Browser Adds for You
An implicit track is one you never declared — the browser adds it automatically when content needs a row or column the explicit grid doesn't have. This happens two ways: placing an item on a line number beyond the explicit grid's range, or simply running out of explicit cells during auto-placement, which is the far more common case in practice.
.gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: minmax(140px, auto);
}
.gallery only declares columns. Drop ten images
into it and the browser fills three per row, then keeps adding
rows — implicit ones — for as many images as there are, without
needing a row count decided in advance. grid-auto-rows
is what sizes those implicit rows; leave it unset and it
defaults to auto, meaning each implicit row is
sized purely by its own content, with no guaranteed relationship
to any other row's height.
grid-auto-columns is the column-axis equivalent,
used when grid-auto-flow: column makes the grid
grow sideways instead of downward. Whichever axis your explicit
template doesn't cover is the axis grid-auto-* is
responsible for — it's easy to set the explicit dimension
carefully and forget the implicit one entirely, which is exactly
the gap pitfall five returns to.
§ 05
Placement: Lines, Spans, and Auto-Flow
An item can be placed explicitly by line number, by name, or by a span count relative to wherever it starts:
.hero {
grid-column: 2 / 4; /* from line 2 to line 4 */
grid-row: 1 / span 2; /* start at line 1, cover 2 tracks */
}
Anything not explicitly placed falls to
auto-placement: the browser walks the grid in
the order set by grid-auto-flow —
row by default, filling left-to-right then dropping
to the next row, or column to fill top-to-bottom
then move to the next column — and drops each item into the
first open cell it finds, skipping any cell an explicitly
placed item already occupies.
Adding the dense keyword —
grid-auto-flow: row dense; — changes that search
from "first open cell after wherever we last placed something"
to "first open cell anywhere, including ones skipped earlier,"
which backfills gaps left by irregularly sized items instead of
leaving holes. It's a purely visual reordering: items can render
out of their source order, while focus order, screen-reader
order, and the DOM itself are untouched — a mismatch worth
designing around deliberately rather than discovering by
accident, covered again in pitfall four.
§ 06
grid-template-areas: A Layout You Can Read
Line-based placement is precise, but it doesn't read like a
layout — it reads like coordinates. grid-template-areas
trades that precision for legibility: name each region, arrange
the names into rows of ASCII art that mirror the actual page,
and let each item claim its named area instead of a line range.
.page {
display: grid;
grid-template-columns: 200px 1fr 160px;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header header header"
"sidebar main aside"
"footer footer footer";
gap: 20px;
}
.site-header { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
.aside { grid-area: aside; }
.site-footer { grid-area: footer; }
Each quoted string is one row; each word in it is one column's
area for that row. Repeating a name across adjacent cells — in
either direction — spans that area across all of them, which is
how header claims the full three-column width in
row one without a single span keyword anywhere.
The shape has to stay rectangular: every row needs the same
number of cells, and a name can only repeat in a contiguous
block, not scattered around the grid — section nine covers what
happens when that rule gets broken.
The other advantage shows up at a breakpoint: reflowing the entire page is one property, not a rewrite of every item's placement.
@media (max-width: 640px) {
.page {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"main"
"sidebar"
"aside"
"footer";
}
}
Every element keeps the same grid-area declaration
it always had — only the map they're placed against changes,
including the reading order, since sidebar now falls after main
instead of before it.
§ 07
Alignment on Two Axes
Grid uses the CSS Box Alignment properties on both of its axes,
which is where the justify-* /
align-* split actually earns its keep:
justify-* always addresses the inline axis (columns,
in a horizontal writing mode), align-* always
addresses the block axis (rows). The same four-word pattern
repeats at three different scopes:
| Property | Scope | Default |
|---|---|---|
| justify-items / align-items | Every item, inside its own cell | stretch |
| justify-self / align-self | One item, overriding *-items | auto (inherits *-items) |
| justify-content / align-content | The whole track set, inside the container | start |
The distinction that trips people up is items versus
content. *-items and *-self
position something inside a cell that's already the right size
— center a short label inside a taller row, for instance.
*-content only does anything when the tracks
themselves don't consume the full container — fixed-size
tracks inside a wider grid, for example — and it distributes
that leftover space between, around, or outside the tracks,
the same way justify-content already does on a
single flex line, just now available on the row axis too.
place-items, place-content, and
place-self are shorthands for the align/justify
pair — align value first, justify
second:
place-items: center;
And gap (plus row-gap /
column-gap individually) puts space between
tracks directly, without the negative-margin-on-the-container
trick that used to be the only way to space grid cells evenly.
It only affects space between tracks, never the outer edge —
padding on the grid container still handles that.
§ 08
Subgrid: Inheriting a Parent's Rhythm
Nest a grid inside a grid item by default, and the nested grid computes its own tracks from scratch, sized to its own content — entirely independent of the parent's tracks, even when the numbers happen to coincide. A row of card components laid out this way, each internally a small grid for image / title / body / footer, will never have its rows line up across cards: card A's two-line title pushes its body down; card B's one-line title doesn't; nothing connects the two, because each card's grid never knew the other cards existed.
subgrid is the fix, and it's a specific,
narrow behavior: instead of computing new tracks, a subgridded
axis reuses the exact track sizes its parent already
established for the lines it spans.
.card-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.card {
grid-row: span 4; /* image, title, body, footer */
display: grid;
grid-template-rows: subgrid;
}
.card-row is an ordinary three-column parent grid.
Each .card spans four of the parent's row lines and
declares grid-template-rows: subgrid instead of
sizing its own four rows — so those four rows literally
are the parent's shared row tracks, not a lookalike
copy of them. That matters beyond visual alignment: because
the rows are shared, the track-sizing algorithm considers every
subgridded card's content when it sizes each row, so the
tallest title across all three cards sets that row's height for
every card — with no JS measuring pass and no fixed heights
guessed in advance.
Rows and columns subgrid independently — a component can inherit the parent's column tracks while still sizing its own rows normally, whichever axis actually needs to share the parent's rhythm. Support is solid across every current major evergreen browser, so it's safe to reach for by default rather than treated as an edge-case feature; the main judgment call is recognizing when a nested grid actually needs its parent's tracks versus when an independent nested grid was the right call all along — a one-off component with no siblings to align against rarely benefits from it.
§ 09
Five Pitfalls That Look Like Grid Bugs
-
01
A track sized 1fr can still overflow its container. Grid items default to
min-width: auto/min-height: auto, which resolves to the content's own minimum size — an unbreakable word, a wide image — and that minimum wins over1frwhenever the two disagree, pushing the track wider than intended and overflowing the grid. Useminmax(0, 1fr)instead of a bare1frwherever a track holds text or media, the same fix in spirit as settingmin-width: 0on an unyielding flex child. -
02
A malformed grid-template-areas fails silently. Rows with different cell counts, or a name that repeats in a non-rectangular shape, make the whole property invalid — and an invalid declaration is simply dropped, with no console warning to point at the mistake. If a named layout isn't rendering at all, check that every quoted row has the same number of words before looking anywhere else.
-
03
Negative line numbers only count the explicit grid.
grid-column: 1 / -1;is a common shorthand for "span the full width," and it works — until auto-placed content adds implicit columns beyond the explicit template.-1still refers to the last line of the explicit grid, not whatever the grid has grown to, so a full-width item stops being full-width the moment an implicit track appears past it. -
04
grid-auto-flow: dense reorders visually, not in the DOM. It's a legitimate way to backfill gaps left by irregular item sizes, but it only ever changes paint order — tab order and screen-reader order still follow the source. Used without checking that gap, a sighted mouse user and a keyboard user can end up experiencing two different orderings of the same page.
-
05
An unset grid-auto-rows leaves implicit rows sized by content alone. It's easy to carefully tune the explicit rows and forget the axis
grid-auto-rowscovers entirely, especially in a grid whose item count isn't known ahead of time. The result is implicit rows with no consistent height relationship to the explicit ones or to each other. Settinggrid-auto-rows: minmax(120px, auto);explicitly — even just as a floor — is worth doing any time a grid's row count isn't fully known upfront.
§ 10
Quick Reference
| Concept | Snippet / value | Where it matters |
|---|---|---|
| Turn it on | display: grid; |
Everything else in this table depends on it |
| Explicit tracks | grid-template-columns: repeat(3, 1fr); |
The tracks you control directly |
| Fluid tracks |
repeat(auto-fit, minmax(240px, 1fr))
|
Zero-breakpoint column count |
| Implicit track size | grid-auto-rows: minmax(120px, auto); |
Sizes rows the browser adds for you |
| Line-based placement | grid-column: 2 / span 3; |
Precise, coordinate-style positioning |
| Named placement | grid-template-areas: "a a" "b c"; |
A layout that reads like a floor plan |
| Overflow guard | minmax(0, 1fr) instead of 1fr |
Stops content forcing a track wider than intended |
| Nested alignment | grid-template-rows: subgrid; |
Child inherits the parent's own row tracks |
| Per-axis alignment | justify-items · align-items |
Inline axis vs. block axis, inside each cell |
| Track spacing | gap: 24px; |
Space between tracks, no negative-margin hacks |
None of this is about memorizing every Grid property at once —
it's about knowing which two or three actually apply to the
layout in front of you. Declare the explicit tracks you want,
let fr and minmax() handle sizing
fluidly instead of guessing pixel widths, name the shape with
grid-template-areas once it's complex enough that
coordinates stop being legible, and reach for
subgrid the moment nested content needs to share a
parent's rhythm rather than reinvent it. Two-dimensional control
is a genuinely different tool than a one-axis flex line — not a
fancier version of it — and most of what looks like a Grid bug
is really one of the handful of defaults in section nine, not a
gap in the model itself.