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
$3
$45
$12
$84
$9
$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.
A grid is a container of tracks
Section titled “A grid is a container of tracks”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.
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
200pxor16rem. The track is exactly that wide and never changes. Reach for this when a sidebar’s width is part of the design. - The
frunit, 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:
autosizes to the content, whilemin-contentandmax-contentgive 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 belowminor abovemax. 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.
The fr unit and the minmax floor
Section titled “The fr unit and the minmax floor”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:
When a grid overflows, the fix is almost always to stop hand-writing 1fr: use the utility, or write minmax(0, 1fr) in full.
Tailwind grid utilities by job
Section titled “Tailwind grid utilities by job”A compact map of the grid utilities, grouped by the job each does. Skim it once, then use it as a lookup.
| Job | Utilities | What they do |
|---|---|---|
| Define tracks | grid-cols-*, grid-rows-* | Numeric (grid-cols-3) for equal minmax(0,1fr) tracks, or bracket form (grid-cols-[200px_1fr]) for explicit sizes. |
| Spacing | gap-*, 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 tracks | col-span-*, row-span-*, col-span-full | Make one item cover several tracks (or the whole row). |
| Place by line | col-start-*, col-end-*, row-start-*, row-end-* | Pin an item to specific gridlines. |
| Implicit tracks | auto-cols-*, auto-rows-* | Size the rows/columns the grid creates automatically. |
| Auto-placement | grid-flow-row, grid-flow-col, grid-flow-dense | Direction 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.
<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>Design-driven. Reach for this when the spec names exactly N columns per breakpoint: one on mobile, two on tablet, three on desktop. You name the counts instead of letting the container pick. This is the catalog answer from the intro.
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.
Page shells with named template areas
Section titled “Page shells with named template areas”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.
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.
grid-area: header grid-area: sidebar grid-area: main 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.
grid-area: header grid-area: sidebar grid-area: main grid-area: footer One column, four rows: grid-template-areas: 'header' 'main' 'sidebar' 'footer'. Only the tracks and the area strings changed; every child kept its [grid-area:…], and the sidebar now reads below main.
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.
Subgrid for cross-card alignment
Section titled “Subgrid for cross-card alignment”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.
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.
grid-rows-subgrid makes each card adopt the parent’s rows, so titles, images, and buttons all line up, however long any one title runs.
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.
Aligning items and tracks
Section titled “Aligning items and tracks”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-*(longhandsitems-*andjustify-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-*(longhandscontent-*andjustify-content-*). If three200pxcolumns use 700 pixels of a 1000-pixel container,place-content-centercenters 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.
Spanning and pinning items
Section titled “Spanning and pinning items”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.
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.
Five grid layouts to reuse
Section titled “Five grid layouts to reuse”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.
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.
Resize the page — the grid picks the column count itself.
Container-driven, no breakpoints: grid grid-cols-[repeat(auto-fit,minmax(16rem,1fr))] gap-6. The grid measures its own width and picks the column count; resize the page and it reflows on its own.
The app-shell skeleton: grid-cols-[200px_1fr] grid-rows-[auto_1fr_auto] [grid-template-areas:'header_header''sidebar_main''footer_footer'] with each region claiming its [grid-area:…]. Header and footer span both columns; the sidebar holds the fixed track and main takes the 1fr.
Dead-center on both axes: grid place-items-center min-h-dvh. One grid, one item, the most concise full-screen centering in CSS, the go-to for an auth hero or an empty state.
col-span-2 row-span-2One tile bigger than the rest: grid grid-cols-3 gap-4 with col-span-2 row-span-2 on the Revenue tile. The four smaller tiles auto-place around the 2×2 hero.
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.
Flex or grid: the decision
Section titled “Flex or grid: the decision”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.
Equal columns are grid’s home turf. Use grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 when the design names the counts, or grid-cols-[repeat(auto-fit,minmax(16rem,1fr))] when only a minimum card width matters and the count falls out of the container.
A header / sidebar / main / footer shell is two-dimensional. Define the tracks and a [grid-template-areas:…] map, then let each region claim its [grid-area:…]. For a simpler header / main / footer with no sidebar, plain col-span-full reads cleaner than a named template.
Cross-card alignment of internal sections is what subgrid solves. Make each card span the parent’s rows (row-span-4) and declare grid-rows-subgrid so its title, image, and body land on the shared lines.
A featured tile is a span on one item in an ordinary grid: grid grid-cols-3 gap-4 with col-span-2 row-span-2 on the hero tile, and auto-placement flows the rest around it.
One axis distributing leftover space is flexbox’s job: flex items-center justify-between for a nav bar, or a flex-1 spacer to push two toolbar clusters apart.
A line of content-sized items, an icon beside a label or a cluster of buttons, is a one-dimensional flex row. No tracks needed; the items size to themselves.
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.
External resources
Section titled “External resources”The complete CSS grid reference: every property, with interactive examples.
How Tailwind's grid utilities map to CSS, including the bracket/arbitrary forms.
Josh Comeau builds the grid mental model with live, draggable demos: fr units, areas, alignment.
Learn grid by playing: 28 levels of writing real grid CSS to water your carrots.