Skip to content
Chapter 20Lesson 4

Grid, the 2D primitive

CSS grid in Tailwind for card grids, page shells, and dashboards, and how to know when to reach for it over flexbox.

Last lesson you learned flexbox, the one-dimensional primitive: give it a row or column of items with unpredictable widths, and it distributes the leftover space among them. That’s the right tool for a nav bar, a toolbar, or a form row, where you have one axis and variable content.

Now picture a product catalog: three columns on desktop, two on a tablet, one on a phone, every card the same width, with consistent gaps in both directions. Reach for the tool you know, flex flex-wrap gap-6, and you get something almost right.

flex flex-wrap ragged, orphaned last card

Pen

$3

Wireless Mouse

$45

USB-C Cable

$12

Monitor Arm

$84

Mug

$9

Desk Lamp

$38

grid grid-cols-3 equal columns, tidy rows

Pen

$3

Wireless Mouse

$45

USB-C Cable

$12

Monitor Arm

$84

Mug

$9

Desk Lamp

$38

Same six cards. flex flex-wrap on the left sizes each card to its content, so the columns come out ragged and the last card orphans onto its own row. grid grid-cols-3 on the right gives three equal columns and two tidy rows.

Flex items size to their content, so the columns come out ragged and the last card won’t line up. That isn’t a flaw: distributing space along one axis is all flex set out to do. Equal columns and a clean two-dimensional structure are grid’s job:

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>

By the end of this lesson that line will be obvious, and you’ll have four more grid patterns: the page shell that frames a dashboard (header, sidebar, main, footer), the one-liner that centers a hero, the dashboard with one tile bigger than the rest, and the card grid that reflows with no breakpoints at all. You’ll also be able to look at any layout and decide, flex or grid, on sight.

Flexbox set up the frame: display makes a parent a container, and its direct children become items the algorithm arranges. Grid reuses that frame exactly.

Set display: grid (Tailwind grid) on an element and it becomes a grid container ; every direct child becomes a grid item , with no per-child opt-in. The difference from flex is what the container defines. A flex container sets one direction and lets content widths fall where they may; a grid container lays out an explicit two-dimensional scaffold up front, a set of columns and rows. Each column or row is a track , and where a column and a row cross they form a cell . Items drop into cells left to right and top to bottom, filling row by row, unless one asks for a specific spot.

So flex distributes space along one axis and lets items size themselves; grid defines a structure on two axes that items snap into. You design the grid, and the content follows.

The container declares its tracks with grid-template-columns and grid-template-rows, written in Tailwind as grid-cols-* and grid-rows-*. Here is the simplest useful grid: six cards, three equal columns, a gap between them.

<div className="grid grid-cols-3 gap-4">
<Card>One</Card>
<Card>Two</Card>
<Card>Three</Card>
<Card>Four</Card>
<Card>Five</Card>
<Card>Six</Card>
</div>

grid-cols-3 declares three column tracks. You never declared rows, so the grid creates them as needed: six cards into three columns means two rows, made automatically. The cards flow in source order, one through three across the first row, then four through six across the second, each landing in the next free cell. gap-4 sets the space between tracks in both directions at once. The cards carry no widths and no per-item rules, so the whole structure lives on the parent.

1
2
3
4
5
6

Auto-placement order of the six-card grid above. With no per-item rules, card n lands in cell n: items flow into the next free cell left to right, then wrap to the next row. The gap shows as the gutters between cells.

Most grids need no more than this: declare the columns and let the items fall in.

Before the responsive techniques, it helps to know the range of sizes a track can take, since everything later is a variation on these. A track’s size can be:

  • A fixed length, like 200px or 16rem. The track is exactly that wide and never changes. Reach for this when a sidebar’s width is part of the design.
  • The fr unit, a fraction of the leftover space. This is the workhorse for “this column grows with the container,” and the next section is entirely about it.
  • A content keyword: auto sizes to the content, while min-content and max-content give the narrowest or widest the content can be. These earn their keep mostly in data tables, where a column should hug its longest value.
  • minmax(min, max), a track that won’t go below min or above max. This one turns up almost everywhere, and you’ll see why in a moment.

When a grid item isn’t where you expected, open the overlay rather than guessing at utilities. Chrome’s Elements panel puts a small grid badge next to any grid container; click it and Chrome draws the track lines onto the page, and the Layout tab toggles line numbers, line names, and area names. With the overlay on, you can see at a glance why a card landed where it did, the same instinct as the flex overlay from the last lesson: inspect the structure the browser built before you change classes.

Fixed-width columns are easy to picture. The fr unit is more interesting, and it hides the detail behind the most common grid bug.

The fr unit is one share of the leftover space. The grid subtracts every fixed and content-sized track from the container’s width, then splits what remains among the fr tracks in proportion to their numbers. Three 1fr columns each take a third. A 200px 1fr pair gives a fixed 200-pixel sidebar and a main column that takes everything else, the classic two-column app layout in one track definition. It is flex’s flex-1 idea, leftover space as a layout tool, now in one dimension of a grid.

So is grid-cols-3 just three 1fr columns? Almost, and the gap between almost and exactly is the bug. Here is what it compiles to:

grid-template-columns: repeat(3, minmax(0, 1fr));

Each column is minmax(0, 1fr): as small as 0, as large as one share of the leftover space. That 0 floor matters because of the default it overrides.

A plain 1fr track has an implicit minimum, it won’t shrink below the minimum content size of what’s inside. Put a long unbreakable string in a 1fr column, a URL, an API key, or a font-mono token, and the column refuses to go narrower than that string, holding its width even when that pushes the grid past its container and forces a horizontal scrollbar. This is the flexbox problem from the last lesson, where a flex-1 item wouldn’t shrink past its content until you added min-w-0. The minmax(0, …) floor is the grid’s fix: 0 lets the column shrink below its content, so it clamps to its share and the long string wraps or clips instead of bursting the layout.

The bug appears only when you bypass the utility and hand-write the track template:

<div className="grid grid-cols-[repeat(3,1fr)] gap-4">

That bracket form is raw repeat(3, 1fr) with no minmax, so each column keeps its content-width floor and a single long word can overflow the grid on a narrow viewport. Try it:

Flip the toggle to 1fr: the column holding the long token refuses to shrink, so it widens while its neighbors are squeezed and the grid overflows its frame (scroll to see). Flip back to minmax(0,1fr), the Tailwind default, and every column stays equal while the token clips. This is why plain grid-cols-3 never produces this bug.

When a grid overflows, the fix is almost always to stop hand-writing 1fr: use the utility, or write minmax(0, 1fr) in full.

A compact map of the grid utilities, grouped by the job each does. Skim it once, then use it as a lookup.

JobUtilitiesWhat they do
Define tracksgrid-cols-*, grid-rows-*Numeric (grid-cols-3) for equal minmax(0,1fr) tracks, or bracket form (grid-cols-[200px_1fr]) for explicit sizes.
Spacinggap-*, gap-x-*, gap-y-*One gutter between tracks, both axes at once, or split per axis. The default spacing tool inside any grid, in place of per-item margins.
Span trackscol-span-*, row-span-*, col-span-fullMake one item cover several tracks (or the whole row).
Place by linecol-start-*, col-end-*, row-start-*, row-end-*Pin an item to specific gridlines.
Implicit tracksauto-cols-*, auto-rows-*Size the rows/columns the grid creates automatically.
Auto-placementgrid-flow-row, grid-flow-col, grid-flow-denseDirection the auto-placer fills cells; dense backfills gaps.

Two utilities carry caveats worth keeping in mind. gap is the only spacing tool you need inside a grid: it sets row and column gutters together, spaces items without leftover edge margins, and reflows when the item count changes. A later lesson makes the full case for it; here, take it as the default. grid-flow-dense shares the accessibility caveat of flexbox’s *-reverse: it reorders items visually to backfill gaps, but DOM order, tab order, and screen-reader order stay put. Reach for it only when visual order and reading order need not match.

Responsive card grids: breakpoints vs auto-fit

Section titled “Responsive card grids: breakpoints vs auto-fit”

Back to the catalog. The intro used breakpoint variants, grid-cols-1 md:grid-cols-2 lg:grid-cols-3, for “one column on mobile, two on tablet, three on desktop.” That’s the right tool when the design specifies counts. A second strategy needs no breakpoints at all, and choosing between them is a deliberate design decision.

The breakpoint-free form reads almost as a sentence:

grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));

Create as many equal columns as fit, each at least 16rem wide, each growing to fill the leftover space. The grid measures its own width and picks the column count itself: four columns on a wide screen, two on a tablet, one on a phone, recomputed as the container resizes. No breakpoints and no media queries . In Tailwind it’s the bracket form: grid-cols-[repeat(auto-fit,minmax(16rem,1fr))].

The choice between the two follows from what the design specifies:

<div className="grid grid-cols-[repeat(auto-fit,minmax(16rem,1fr))] gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>

Container-driven, zero breakpoints. Reach for this when the column count doesn’t matter and you only need each card to stay at least 16rem wide while the grid fits as many as it can. The count falls out of the container width.

auto-fit has a near-twin, auto-fill. Both create as many tracks as fit; they differ only when there aren’t enough items to fill the last row. auto-fit collapses the empty trailing tracks to zero width, so the items present stretch across the whole row. auto-fill keeps those phantom tracks at their minimum width, leaving visible gaps where the missing cards would be. For a card grid you almost always want auto-fit. Reach for auto-fill only when the empty slots should stay reserved, like a fixed gallery whose blanks stay blank.

One thing to file away. auto-fit reacts to the container’s width, though in practice the container fills the viewport, so it tracks the viewport. Container queries (@container) let a component respond to its own width instead, which is what you want when one card lives in both a wide main column and a narrow sidebar. That’s a next-chapter topic.

Every dashboard is the same skeleton: a header across the top, a sidebar down one side, the main content filling the rest, a footer at the bottom. This is the app shell , and grid has a feature built almost exactly for it: named template areas.

You draw the layout as a small map of names, then each child says which named region it belongs to. The map reads like ASCII art, so the layout is visible right in the source. Core Tailwind v4 ships no grid-area utility, and a from-scratch 2026 stack won’t add a plugin for what brackets already do: you write the CSS property inside brackets and Tailwind passes it straight through. Here’s the full shell, step by step.

<div className="grid h-dvh grid-cols-[200px_1fr] grid-rows-[auto_1fr_auto] gap-4 [grid-template-areas:'header_header''sidebar_main''footer_footer']">
<header className="[grid-area:header]">Acme</header>
<aside className="[grid-area:sidebar]">Nav</aside>
<main className="[grid-area:main]">Dashboard</main>
<footer className="[grid-area:footer]">© 2026</footer>
</div>

First the tracks. grid-cols-[200px_1fr] is a fixed 200-pixel sidebar plus a main column that takes the rest. grid-rows-[auto_1fr_auto] sizes the header and footer to their content and lets the body grow to fill. h-dvh makes the shell as tall as the viewport so the footer sits at the bottom; dvh gets its own treatment in the next lesson.

<div className="grid h-dvh grid-cols-[200px_1fr] grid-rows-[auto_1fr_auto] gap-4 [grid-template-areas:'header_header''sidebar_main''footer_footer']">
<header className="[grid-area:header]">Acme</header>
<aside className="[grid-area:sidebar]">Nav</aside>
<main className="[grid-area:main]">Dashboard</main>
<footer className="[grid-area:footer]">© 2026</footer>
</div>

Now the map, a 2×3 grid of names: header header (the header spans both columns), sidebar main, then footer footer. The regions lay out exactly as they appear on screen, so you take in the whole layout at a glance.

<div className="grid h-dvh grid-cols-[200px_1fr] grid-rows-[auto_1fr_auto] gap-4 [grid-template-areas:'header_header''sidebar_main''footer_footer']">
<header className="[grid-area:header]">Acme</header>
<aside className="[grid-area:sidebar]">Nav</aside>
<main className="[grid-area:main]">Dashboard</main>
<footer className="[grid-area:footer]">© 2026</footer>
</div>

Underscores stand in for spaces, since a bracket value can’t hold a literal one, so 'header_header' is a single row of two header cells. The row strings sit directly adjacent, so 'header_header''sidebar_main' is two rows, not one.

<div className="grid h-dvh grid-cols-[200px_1fr] grid-rows-[auto_1fr_auto] gap-4 [grid-template-areas:'header_header''sidebar_main''footer_footer']">
<header className="[grid-area:header]">Acme</header>
<aside className="[grid-area:sidebar]">Nav</aside>
<main className="[grid-area:main]">Dashboard</main>
<footer className="[grid-area:footer]">© 2026</footer>
</div>

Each child claims its region with [grid-area:name]. The names must match the template exactly, and the template must be rectangular, every row holding the same number of cells. A name that doesn’t match auto-places the child somewhere unexpected.

<div className="grid h-dvh grid-cols-[200px_1fr] grid-rows-[auto_1fr_auto] gap-4 [grid-template-areas:'header_header''sidebar_main''footer_footer']">
<header className="[grid-area:header]">Acme</header>
<aside className="[grid-area:sidebar]">Nav</aside>
<main className="[grid-area:main]">Dashboard</main>
<footer className="[grid-area:footer]">© 2026</footer>
</div>

gap-4 spaces every region from its neighbors: the same gutter tool, now between named areas.

1 / 1

The payoff shows up when you go responsive. Because the layout lives in that one template string, rearranging the whole shell for a phone is a single change: collapse to one column and restack the regions in reading order. Here’s the desktop shell beside its mobile rearrangement.

Header grid-area: header
Sidebar grid-area: sidebar
Main grid-area: main
Footer grid-area: footer

Two columns, three rows: grid-template-areas: 'header header' 'sidebar main' 'footer footer'. Header and footer span both columns; the sidebar holds the fixed 200px track and main takes the 1fr.

Only the container changed between those tabs: its track template and its grid-template-areas string. Every child kept the exact [grid-area:…] it already had. That’s what naming regions buys you: the whole shell rearranges by editing one string, which on a phone you’d wrap in an md:-style variant.

Named areas earn their place when the two-dimensional arrangement is worth a named template, like a real header-plus-sidebar-plus-footer shell. For a simpler skeleton with no sidebar, a plain multi-column grid with col-span-full on the header and footer (covered next) reads cleaner and saves you the template string. Reach for the heavier tool only when the layout asks for it.

Each card in a row has its own internal structure: a title, an image, a body, a footer button at the bottom. The grid makes the cards equal width, but not equal inside. When one card’s title wraps to two lines and its neighbor’s fits on one, that card’s image starts lower, and the row falls out of step.

subgrid fixes this. Normally a card that’s itself a grid defines its own rows. With grid-rows-subgrid (or grid-cols-subgrid for columns), the card adopts its parent grid’s tracks instead. Every card’s title, image, and body row then lands on the same shared lines, so titles align with titles and images with images across the row, however long any one card’s content runs.

Aurora Lamp

image

Warm bedside glow.

Add to cart

Drift Lounge Chair Set

image

Two-seat oak frame.

Add to cart

Mesa Adjustable Standing Desk Pro

image

Electric sit-stand base.

Add to cart

Each card sizes its own rows. A longer title pushes that card’s image, body, and button down, so the image tops stair-step and the buttons never line up.

Two things make subgrid work, and leaving out either is the usual mistake. First, the card has to span the parent’s tracks: if the shared structure is four rows, the card spans four (for example, row-span-4). Second, the card declares grid-rows-subgrid so its children place onto those inherited lines. With both in place, the card borrows the parent’s tracks and hands them down to its children.

Subgrid is Baseline , so ship it today with no fallback. Reach for it whenever a row of cards needs its internal sections to align.

Grid has the same alignment utilities as flex, now working on two axes. The grid utility you’ll reach for most centers anything:

<div className="grid place-items-center min-h-dvh">
<SignInCard />
</div>

A sign-in card dead-center on the screen, both axes, in three utilities. place-items-center is shorthand for align-items: center and justify-items: center, centering each item within its cell. min-h-dvh makes the grid at least as tall as the viewport (that unit is the next lesson’s topic). Reach for this on an auth hero or an empty state.

Grid answers two different alignment questions that sound alike:

  • Where does each item sit inside its cell? place-items-* (longhands items-* and justify-items-*) moves content within the box its track gives it.
  • Where does the whole track group sit inside the container, when the tracks don’t fill it? place-content-* (longhands content-* and justify-content-*). If three 200px columns use 700 pixels of a 1000-pixel container, place-content-center centers that block of tracks, leaving equal margins on each side.

place-items-center item centred in its cell

place-content-center whole track group centred

Same oversized container on both sides. Left, place-items-center: the grid fills the container with three equal columns and rows, and each small item is centered within its cell. Right, place-content-center: the tracks are smaller than the container, so the whole block of tracks centers as a unit with equal margin all around.

To override the alignment of one item, use place-self-* on that item alone.

Auto-placement handles most grids. Sometimes one item needs to break the pattern: a featured tile twice the size of the rest, or a header that spans every column. That’s explicit placement, in two forms: spanning and pinning.

Spanning is the common one. col-span-2 makes an item occupy two column tracks, row-span-2 makes it two rows tall, and together they make a tile that’s big in both directions. col-span-full stretches an item across every column, however many there are, which is the clean way to lay out a header, footer, or full-width banner. Here’s a stats dashboard where the first tile is the hero:

<div className="grid grid-cols-3 gap-4">
<StatTile className="col-span-2 row-span-2">Revenue</StatTile>
<StatTile>Users</StatTile>
<StatTile>Churn</StatTile>
<StatTile>MRR</StatTile>
<StatTile>Trials</StatTile>
</div>

The Revenue tile takes a 2×2 block in the top-left, and the four smaller tiles flow into the cells around it. One utility on one item gives the dashboard a focal point.

Watch the track count: ask for col-span-3 in a two-column grid and the span clamps to the columns available. There’s no overflow, but there’s no third column either.

Pinning is the precise form, for when “span N” isn’t enough and you need an item at exact coordinates. Grid lines are numbered from 1 at the start edge, so col-start-2 col-end-4 places an item from line 2 to line 4, covering columns 2 and 3. The lines are edges, not tracks: an N-column grid has N+1 vertical lines.

1 2 3 4 1 2 3
vertical lines horizontal lines col-start-2 col-end-4

col-start-2 col-end-4 spans the columns between lines 2 and 4, not lines 2 and 4 themselves.

You can also name gridlines in the track definition and place items against those names, but it’s verbose and rarely worth it in component code. Reach for span counts first, line numbers when you need precision.

Almost every grid in a web app is one of five shapes, and knowing them by name lets you reach for the right one instead of deriving it each time. Each panel below is a live, inspectable grid paired with its utility string.

One
Two
Three
Four
Five
Six

Equal columns at fixed counts: grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6. Shown at the desktop state: three equal minmax(0,1fr) columns, the cards flowing in source order.

Those five: a card grid with fixed counts at breakpoints, a card grid that reflows with auto-fit, a page shell, a centered hero, and a dashboard with a featured tile. Most production grids are one of them or a small variation.

Now build one yourself. Take six product cards that are currently stacked and turn them into the responsive catalog from the top of the lesson: one column on mobile, two on tablet, three on desktop, with a consistent gap. Match the target preview.

These six product cards are stacked full-width, one per row. Add grid utilities to the wrapper <div> so they become a responsive grid — one column on mobile, two on tablet, three on desktop, with a consistent gap. Match the target.

Target
Your output LIVE

You have two layout primitives. The skill that matters is reaching for the right one on sight, before you write a single class. The rule:

  • Flex is one dimension. A single row or column of variable-content items where the algorithm distributes the leftover space: navs, toolbars, button clusters, form rows, vertical stacks.
  • Grid is two dimensions. Items snap to rows and columns: when columns must be equal or exactly counted, when sections must align across items, or when the column count should respond to width. Card grids, page shells, dashboards.

And here is the idea that settles “which is better”: most SaaS UIs are a grid of flex compositions. Grid lays out the page regions and card galleries, the big two-dimensional structure; flex arranges the contents inside each region and card, the nav in the header, the title-and-price row in a product card, the footer buttons. They don’t compete, they nest: grid for the skeleton, flex for what fills it.

When a layout sits in the gray zone, walk these questions in order. The first “yes” usually decides it.

Flex or grid?

The walker asks about the second axis first because that question does most of the work: a second axis to align means grid, and the follow-ups only pick which grid feature; no second axis means flex, and the only question left is whether you’re distributing space or hugging content.

Now sort each of these real SaaS surfaces into the primitive you’d reach for first:

Sort each SaaS surface into the primitive you'd reach for first. Ask the splitting question: does a second axis need to line up? Drag each item into the bucket it belongs to, then press Check.

Reach for flex One axis, distribute space
Reach for grid Two axes, snap to tracks
Horizontal nav bar (logo left, links right)
Pricing card grid, three across
Icon + label inside a button
Dashboard with one featured tile
Vertical settings list
App shell with a sidebar
Toolbar with a spacer pushing two clusters apart
Product catalog that reflows by container width