// 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.

INTERMEDIATE · 15 MIN READ · WEB LAYOUT

§ 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.

Flexbox's single axis versus Grid's two axes Top: a flex row of differently sized items wrapping onto a second line whose item edges don't line up with the first line's edges, since flexbox only ever reasons about one axis at a time. Bottom: a CSS grid with four numbered column lines and three numbered row lines, where every cell's edges align exactly across both rows and columns, and one item spans from column line 1 to column line 3. FLEXBOX — ONE AXIS (ROW), WRAPPED main axis Row edges don't line up — the cross axis only aligns items, it never defines a track. CSS GRID — TWO AXES (ROWS × COLUMNS) grid-column: 1 / 3 1 2 3 4 1 2 3
FIG. 01 — A flex row's wrapped second line shares no column edges with the first. A grid's rows and columns are one shared matrix, numbered from its lines, so every cell aligns by definition and an item can span more than one of either.

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-flowrow 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.

grid-template-areas rendered as a floor plan A header bar spanning the full width, a sidebar/main/aside row of three columns beneath it, and a footer bar again spanning the full width, with dashed vertical lines showing the underlying column tracks running continuously through the header and footer even where those areas span across them. header sidebar main aside footer col 1 col 2 col 3
FIG. 02 — Three quoted strings become three rows; repeating a name across columns spans that area across them. The column tracks (dashed) run underneath the whole grid, even through the header and footer areas that span across every one of them.

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:

/ grid_alignment_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 over 1fr whenever the two disagree, pushing the track wider than intended and overflowing the grid. Use minmax(0, 1fr) instead of a bare 1fr wherever a track holds text or media, the same fix in spirit as setting min-width: 0 on 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. -1 still 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-rows covers 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. Setting grid-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

/ css_grid_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.

WEB LAYOUT CSS GRID LAYOUT SYSTEMS SUBGRID CSS