Grid, the 2D primitive
CSS grid in Tailwind, the two-dimensional layout primitive for card grids, page shells, and dashboards, and how to know when to reach for it over flexbox.
In the last lesson you learned flexbox, the one-dimensional primitive. You give it a row or a column of items with unpredictable content widths, and its algorithm distributes the leftover space among them. That’s the right tool for a nav bar, a toolbar, a form row, or a stack of cards: one axis, variable content, and the algorithm sorts out the slack.
Now picture a different shape: a product catalog. Three columns on a desktop, two on a tablet, one on a phone. Every card the same width as its neighbors, with a consistent gap between them in both directions. If you reach for the tool you already know, flex flex-wrap gap-6, you’ll get something that’s almost right, and that “almost” is the kind that ships to production and then keeps nagging at you afterward.
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 that last card refuses to line up with the others. This isn’t flex falling short. Distributing leftover space along one axis is all flex ever set out to do, and it does that well. Equal columns and a clean two-dimensional structure were never its job.
That job belongs to grid. Here’s the entire answer to the catalog, and the rest of the lesson is about learning to read it fluently:
<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 in hand: the page shell that frames every dashboard (header, sidebar, main, footer), the one-liner that centers a hero on the screen, 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. That decision is what makes this chapter’s two-primitive split fall into place.
A grid is a container of tracks
Section titled “A grid is a container of tracks”Flexbox gave you a frame worth keeping, and grid reuses it exactly, which is the fastest way in. Set display: flex on a parent and it becomes a container, and its direct children become items the algorithm arranges.
Set display: grid (Tailwind grid) on an element and it becomes a grid container . Every direct child becomes a grid item , same as flex, with no per-child opt-in. But where a flex container defines a single direction and lets content widths fall where they may, a grid container defines an explicit two-dimensional scaffold up front: a set of columns and a set of rows. Each column or row is a track . Where a column track and a row track cross, they form a cell . Items drop into cells left to right and top to bottom, filling row by row, unless an item asks for a specific spot.
That’s the whole shift. Flex distributes space along one axis and lets the items size themselves, whereas grid defines a structure on two axes that the items snap into. You design the grid, and the content follows it.
The container defines its tracks with two properties: grid-template-columns and grid-template-rows. In Tailwind, the everyday form is grid-cols-* and grid-rows-*. Here’s the simplest grid that does something useful: 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, two, three across the first row, then four, five, 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 structure lives entirely on the parent.
Auto-placement order of the six-card grid above. With no per-item rules, card n lands in cell n: the items flow into the next free cell left to right, then wrap to the next row. The gap shows as the gutters between cells.
That figure shows what “auto-placement” means: with no instructions, item n lands in cell n. Most grids you write never need more than this. You declare the columns and let the items fall in.
Before the responsive techniques, it’s worth knowing the range of sizes a track can take, because 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. This is what you reach for 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:
auto(size to the content), or the escape hatchesmin-contentandmax-content(the narrowest or widest the content can be), andfit-content(). You’ll rarely writemin-contentormax-contentin component layouts; they earn their keep mostly in data tables, where a column should hug its longest value. It’s enough to know they exist. 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.
One more thing belongs here, less a concept than a habit. 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 straight onto the page. The Layout tab toggles line numbers, line names, and area names independently. With the overlay on, you can see at a glance why a card landed where it did. It’s the same instinct as the flex overlay from the last lesson: inspect the structure the browser actually built before you start changing classes.
The fr unit and why grid-cols-3 already shrinks
Section titled “The fr unit and why grid-cols-3 already shrinks”Fixed-width columns are easy to picture. The interesting unit is fr, and it hides a detail that explains the most common grid bug once you understand it.
The fr unit means one fraction of the leftover space. The grid first subtracts every fixed and content-sized track from the container’s width, then splits whatever remains among the fr tracks in proportion to their numbers. Three 1fr columns each take a third of the leftover. A 200px 1fr pair gives a fixed 200-pixel sidebar and a main column that swallows everything else, which is the classic two-column app layout in a single track definition. A phrase from the last lesson carries over here: fr is leftover space as a layout tool, the same idea as flex’s flex-1, now expressed in one dimension of a grid.
So is grid-cols-3 three equal 1fr columns? Almost, and the gap between “almost” and “exactly” is what causes the bug. Here’s what Tailwind’s grid-cols-3 actually compiles to:
grid-template-columns: repeat(3, minmax(0, 1fr));Not repeat(3, 1fr), but three columns of minmax(0, 1fr). Read that minmax as: each column can be as small as 0 and as large as one fraction of the leftover space. That 0 lower bound is doing critical work, and to see why you need to know 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 it. Drop a long unbreakable string into a 1fr column, say a URL, an API key, or a font-mono token, and the column refuses to go narrower than that string. It holds that width even when doing so pushes the whole grid wider than its container and forces a horizontal scrollbar. This is the same problem flexbox had in the last lesson, where a flex-1 item wouldn’t shrink past its content until you added min-w-0. Grid behaves the same way, and Tailwind’s numeric grid-cols-* utility fixes it for you by baking in the minmax(0, …) lower bound. The 0 says the column is allowed to shrink below its content, so it clamps to its share of the space and the long string wraps or clips instead of bursting the layout.
This is exactly where the bug lives. It only appears 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. Plain grid-cols-3 avoids this because it already wrote the minmax(0, …) for you, so only the hand-written 1fr form causes trouble. Try it yourself:
Keep that one toggle in mind whenever you debug an overflowing grid. The fix is almost always to stop hand-writing 1fr: use the utility, or write the minmax(0, 1fr) out in full yourself.
Tailwind’s grid utilities at a glance
Section titled “Tailwind’s grid utilities at a glance”Before the patterns, here’s a compact map of what’s available, grouped by the job each utility does. You don’t need to memorize it. Skim it once so the pattern sections read fluently, and come back to 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 notes are worth carrying forward. First, gap is the only spacing tool you need inside a grid. It sets row and column gutters together, adds space between items with no leftover margin at the edges, and reflows correctly when the item count changes. The full case for why gap replaced the old sibling-margin tricks gets its own lesson later in this chapter; here, just take it as the default. Second, grid-flow-dense carries the same accessibility caveat as flexbox’s *-reverse: it reorders items visually to backfill gaps, but the DOM source order stays put, and so do keyboard tab order and screen-reader order. Reach for it only when visual order and reading order genuinely don’t need to match.
Responsive card grids without media queries
Section titled “Responsive card grids without media queries”Back to the catalog. The intro’s answer used breakpoint variants, grid-cols-1 md:grid-cols-2 lg:grid-cols-3, to say “exactly one column on mobile, two on tablet, three on desktop.” That’s the right tool when the design specifies counts. There’s also a second strategy that needs no breakpoints at all, and choosing between the two is a small design decision worth making deliberately.
The breakpoint-free form leans on a function you can read 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 decides the column count itself: four columns on a wide screen, two on a tablet, one on a phone. It recomputes as the container resizes. No breakpoints , no media queries , no md: anything. In Tailwind it’s the bracket form: grid-cols-[repeat(auto-fit,minmax(16rem,1fr))].
Here are the two strategies side by side. The choice between the tabs isn’t just how to write the grid; it follows from what the design actually 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 exact column count doesn’t matter and you only care that each card stays at least 16rem wide while the grid fits as many as it can. The column count falls out of the container width on its own.
<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 says exactly N columns at each breakpoint: one on mobile, two on tablet, three on desktop. You’re naming the counts, not letting the container pick. This is the catalog answer from the intro.
That auto-fit keyword has a near-twin that trips people up: auto-fill. Both create as many tracks as fit, and the difference shows up 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 to fill the whole row. auto-fill keeps those empty phantom tracks at their minimum width, so a half-empty last row leaves visible gaps where the missing cards would be. For a card grid you almost always want auto-fit, because items filling the available width reads as intentional. Reach for auto-fill only in the rare case where you want the empty slots reserved, like a fixed gallery whose blanks should stay blank.
One thing to look ahead to. auto-fit reacts to the container’s width, though in practice it’s reacting to the viewport, because the container usually fills it. There’s a component-scoped cousin, container queries (@container), that lets a component respond to its own width regardless of the viewport. That’s what you reach for when the same card component lives in both a wide main column and a narrow sidebar. It’s a next-chapter topic, so just file the name away for now.
Page shells with named template areas
Section titled “Page shells with named template areas”Every dashboard you’ve ever used 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.
The idea is that you draw the layout as a small map of names, and then each child says which named region it belongs to. The map reads like ASCII art, so you can see the layout right in the source. One catch matters before the syntax makes sense: core Tailwind v4 ships no dedicated utility for grid areas. Plugins exist, but a from-scratch 2026 SaaS stack doesn’t pull in a plugin for something the bracket form already does cleanly. So the canonical v4 approach is the arbitrary value: you write the CSS property name 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 column and a main column that takes the rest. grid-rows-[auto_1fr_auto] is a header sized to its content, a body that grows to fill, and a footer sized to its content. h-dvh makes the shell as tall as the viewport so the footer sits at the bottom. The dvh unit 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. Read it as a 2×3 grid of names: row one is header header (the header spans both columns), row two is sidebar main, row three is footer footer (footer spans both). The template lays the regions out exactly as they appear on screen, so you can 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>The underscores stand in for spaces. Inside a Tailwind bracket value you can’t type a literal space, so Tailwind reads _ as one, which makes 'header_header' a single row of two header cells. The row strings sit directly adjacent with no separator between them, 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 here must match the names in the template exactly, and the template has to be rectangular, with every row holding the same number of cells. If a name doesn’t match, the child auto-places somewhere unexpected instead.
<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 real 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 the order you want them to read. Here’s the desktop shell next to its mobile rearrangement, both as real grids you can inspect.
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, and not one of the four region elements was touched. That’s what naming regions buys you: the whole shell rearranges by editing one string, and on a phone you’d wrap that one change in an md:-style variant.
Named areas earn their place when the two-dimensional arrangement is genuinely worth a named template, like a real sidebar-plus-header-plus-footer shell. For a simpler skeleton, just a header, a main, and a footer with no sidebar split, you often don’t need areas at all. A plain multi-column grid with col-span-full on the header and footer (covered in a moment) reads cleaner and saves you the template string. As always, reach for the heavier tool only when the layout asks for it.
Subgrid for cross-card alignment
Section titled “Subgrid for cross-card alignment”Here’s a subtle alignment problem that catches real card grids. Each card in a row has its own internal structure: a title, an image, a body, a footer button pinned at the bottom. The cards are equal width thanks to the grid. But the title on one card runs two lines while its neighbor’s runs one, so that card’s image starts lower than its neighbor’s, and the row goes out of step. The cards line up on the outside while their insides stay ragged.
The fix is subgrid. Normally a card that’s itself a grid defines its own independent rows. With grid-rows-subgrid (or grid-cols-subgrid for the column direction), the card adopts its parent grid’s tracks instead of inventing its own. Every card’s title row, image row, and body row then lands on the same shared lines, so titles align with titles and images align with images across the whole row, no matter how long any individual card’s content is.
The contrast carries the idea, so look at it both ways:
Each card sizes its own rows. A longer title pushes the image, body, and button below it down, so the image tops stair-step and the buttons never line up across the row.
grid-rows-subgrid makes each card adopt the parent’s rows, so titles align with titles, images with images, and buttons with buttons, no matter how 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 relevant tracks. It’s a grid item, so if the shared structure is four rows, the card spans four rows (for example, row-span-4). Second, the card declares grid-rows-subgrid so its own children place onto those inherited lines instead of fresh ones. With both in place, the card borrows the parent’s tracks and hands them down to its children.
You can use this in production today with no reservations. Subgrid is Baseline , supported in every current major browser, so there’s no fallback to write and no polyfill to pull in. Reach for it whenever a row of cards needs its internal sections to align.
Centering and aligning the whole grid
Section titled “Centering and aligning the whole grid”Grid has its own alignment vocabulary, the two-axis cousin of the justify-* and items-* you learned on flex. One pair of these reliably gets confused, so we’ll pin the distinction down with a picture. But first, the grid utility you’ll reach for most often, the one that centers anything:
<div className="grid place-items-center min-h-dvh"> <SignInCard /></div>That’s a sign-in card dead-center on the screen, on both axes, in three utilities. place-items-center is the shorthand for align-items: center and justify-items: center together, centering each item within its cell. min-h-dvh makes the grid at least as tall as the viewport; that viewport unit is the next lesson’s territory, and here it just means “fill the screen height.” One grid, one centered item. This is what you reach for on an auth hero or an empty state, and it’s shorter than any of the flexbox ways to center.
Now for the part that gets confused. There are two different alignment questions in a grid, and they sound alike:
- Where does each item sit inside its cell? That’s
place-items-*(longhandsitems-*andjustify-items-*). It moves the content around within the box the track gives it. - Where does the whole track group sit inside the container, when the tracks don’t fill it? That’s
place-content-*(longhandscontent-*andjustify-content-*). If your three200pxcolumns only use 700 pixels of a 1000-pixel container,place-content-centercenters that whole block of tracks, leaving equal margins on the sides.
The difference is hard to picture in words, so here it is side by side:
place-items-center item centred in its cell
place-content-center whole track group centred
Same oversized container on both sides. On the left, place-items-center: the grid fills the container with three equal columns and rows, and each small item is centered within its cell. On the right, place-content-center: the tracks are small and don’t fill the container, so the whole block of tracks centers as a unit, leaving equal empty margin all around. place-items-* positions each item within its cell; place-content-* positions the whole track group within the container.
For the everyday case, full-screen centering of a single card, grid place-items-center min-h-dvh is all you need. place-content is the tool for the rarer case where the tracks are smaller than their container. And if you ever need to override the alignment of just one item, place-self-* does it for that item alone.
Placing items across tracks
Section titled “Placing items across tracks”Auto-placement handles most grids. Sometimes, though, one item needs to break the pattern: a featured dashboard tile twice the size of the rest, or a header that should span every column. That’s explicit placement, and it comes in two forms: spanning and pinning.
Spanning is the one you’ll use constantly. 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 is the clean way to make an item, like a header, a footer, or a full-width banner, stretch across every column of the grid regardless of how many there are. 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, and the dashboard has a focal point.
One thing to watch: if you ask for col-span-3 in a grid that only has two columns, the span clamps to the columns available. You get no overflow, but you don’t get the layout you drew either, so keep your spans within your track count.
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, and col-start-2 col-end-4 places an item from line 2 to line 4 (covering columns 2 and 3). To use start and end fluently you have to picture the lines, which are edges, not tracks: an N-column grid has N+1 vertical lines.
Grid lines are numbered from 1, and 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.
One more form is worth naming. You can give your gridlines names in the track definition (the bracket form, for example [grid-template-columns:[sidebar-start]_200px_[sidebar-end_main-start]_1fr_[main-end]]) and then place items against those names. It reads nicely in a big shell where the line names match your layout language, but it’s advanced and rarely worth the verbosity in component code. So reach for span counts first, line numbers when you need precision, and named lines only when a complex shell earns them.
The grid layouts you’ll reach for
Section titled “The grid layouts you’ll reach for”You now have every piece. In practice, almost every grid you’ll write in a SaaS app is one of five shapes, and knowing them by name lets you reach for the right one without working it out from scratch each time. Here’s the gallery, each one a live, inspectable grid 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.
Five shapes: a card grid with fixed counts at breakpoints, a card grid that reflows itself with auto-fit, a page shell, a centered hero, and a dashboard with a featured tile. That’s the working vocabulary, and most production grids are one of these or a small variation on one.
Now build one yourself. Take six product cards that are currently just 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. The target preview shows the finished layout for you to match.
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”This is what the whole chapter has been building toward. You now have two layout primitives, and the skill that matters isn’t knowing each one in isolation. It’s knowing which one to reach for on sight, before you write a single class. Here’s the rule:
- Flex is one dimension. A single row or a single column of variable-content items where you want the algorithm to distribute the leftover space. Navs, toolbars, button clusters, form rows, vertical stacks. One axis, content-sized items, the algorithm sorts the slack.
- Grid is two dimensions. A rows-and-columns structure where items snap to tracks: columns must be equal or exactly counted, sections must align across items, or you want the column count to respond to width. Card grids, page shells, dashboards.
And here’s the idea that settles the “which is better” question entirely: most SaaS UIs are a grid of flex compositions. Grid lays out the page regions and the card galleries, the big two-dimensional structure. Flex arranges the contents inside each region and each card: the nav bar in the header, the title-and-price row inside a product card, the footer buttons. The two don’t compete, they nest, with grid for the skeleton and flex for what fills it.
When a layout sits in the gray zone, walk the questions in this order. The order matters, because the first “yes” usually decides it:
Equal columns are grid’s home turf. Reach for 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 can fall out of the container.
A header / sidebar / main / footer shell is a two-dimensional structure. Define the tracks and an [grid-template-areas:…] map, then let each region claim its [grid-area:…]. For a simpler header / main / footer with no sidebar split, plain col-span-full reads cleaner than a named template.
Cross-card alignment of internal sections is exactly 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 inside an ordinary grid. grid grid-cols-3 gap-4 with col-span-2 row-span-2 on the hero tile; 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 simple line of content-sized items, like an icon next to a label or a cluster of buttons, is a one-dimensional flex row. No tracks needed; the items size to themselves.
Notice the order the walker put you through: the second axis comes first. Whether you need to align a second axis is the question that does most of the work. If yes, it’s grid, and the follow-ups just pick which grid feature. If no, it’s flex, and the only remaining question is whether you’re distributing space or hugging content. Equal-width columns or exact counts means grid; content-sized items in a line means flex. That’s the reflex to build.
Now put it into practice. Sort each of these real SaaS surfaces into the primitive you’d reach for:
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.
If those felt automatic, you’ve got what this lesson set out to teach. Flex for one axis, grid for two, and the recognition that nearly every screen you’ll build is a grid of flex compositions.
Keep going
Section titled “Keep going”The references below go deeper than this lesson does: the full grid property surface on MDN, and Tailwind’s own grid utility reference for the exact class-to-CSS mapping.
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.