Learning CSS Grid the Hard Way
Everything I Built, I Built With Flexbox
I've built everything I've built with flexbox. Navs, cards, forms, whole page shells. It stretched to cover all of it, and because it kept working I kept not learning grid. It sat in the same folder in my head as service workers: real, useful, worth understanding one day.
Then I picked up a challenge that took the option away. Build a photo gallery, no flexbox for layout. Grid only. I got a fair way in by copying shapes from the brief, and then I hit the wall you hit when you're copying things you don't understand. The layout mostly worked. I couldn't have told you why. And the moment something looked off, I had no idea which property was lying to me.
So I stopped building and wrote this instead. It's not a tutorial, it's the notes I needed before I could go back and finish that gallery knowing what I was doing. If you're coming from flexbox, I'd guess the same things will trip you.
The assumption underneath all of it, the one I didn't know I was making: grid is flexbox with a second axis. It isn't. The two disagree about something basic, which is who gets to decide how big things are. Almost everything that confused me comes back to that. Every diagram below is a real grid, so move the controls and watch the CSS change.
Content-Out vs Layout-In
Here's the model I replaced it with, and I'm putting it first because the rest of this article is just it showing up in different places: flexbox is content-out, grid is layout-in.
In flexbox you describe how items should behave and let the content settle the result. You say "grow, shrink, don't go below your basis," and the text inside each item decides the actual widths. In grid you define the structure first, a set of tracks that exist whether or not anything is in them, and then you put items into it.
This is also why the usual advice never landed for me. Everyone says flexbox is one-dimensional and grid is two-dimensional, and that falls apart fast. Plenty of single-row layouts want grid. Plenty of wrapping flex rows look two-dimensional. The question that actually seems to decide it is who should control the size here, the content or the container?
| Situation | Reach for | Because |
|---|---|---|
| Nav bar, tag list, button row | flexbox | item widths come from their own text |
| Card gallery, image mosaic | grid | tracks should be uniform regardless of content |
| Page shell (header/sidebar/main) | grid | named areas, and rows relate to columns |
| Centering one thing | grid | place-items: center |
| Inside a card (title, body, button) | flexbox | one direction, content-driven |
| Aligning across sibling cards | grid + subgrid | flexbox cannot see into siblings |
Not rules, just where the sizing usually wants to live.
They nest fine, and most real pages use both. Grid for the page skeleton and the gallery, flexbox inside each card. That's not a compromise, it's how they're meant to split the work. Hang on to content-out versus layout-in, because the next eight sections are all the same idea from different angles.
There Are Always Two Grids
The first thing that follows from layout-in thinking took me a few confused minutes to accept: there are always two grids, and the second one gets made for you.
The explicit grid is the one I write down, the tracks in grid-template-columns and grid-template-rows. The implicit grid is what the browser adds when there are more items than declared tracks. It makes rows on demand, and those rows are sized by grid-auto-rows, not by my template.
.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr; /* explicit: 3 columns */
grid-auto-rows: 80px; /* implicit: every invented row */
}.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr; /* explicit: 3 columns */
grid-auto-rows: 80px; /* implicit: every invented row */
}Give that nine items and you get three neat rows. Give it ten and the browser quietly makes a fourth row you never asked for. That's not a bug, that's the implicit grid doing its job. The reason it's worth knowing early: a row sized by grid-auto-rows ignores anything you write in grid-template-rows. So if a row is the wrong height and editing the template does nothing, you're looking at the other grid. Add items in the demo and watch the dashed rows show up.
1fr Is Not flex: 1
A track is a column or a row. You can size tracks with any normal unit, but grid adds one of its own: fr, a share of the leftover space. That word matters more than it looks. The browser lays out the fixed tracks first, takes off the gaps, and only then splits what's left. So 200px 1fr 1fr means "200 pixels, then split the rest in half." The two 1fr tracks match each other, but neither has any fixed relationship to the 200px.
Coming from flexbox this looks exactly like flex: 1, and that's the trap I walked into. Here's the difference: 1fr has a minimum size of auto, so the track won't shrink below whatever its content needs. One long unbroken string or a wide image puts a floor under the track, the track pushes the grid wider than its container, and the page starts scrolling sideways. The content wins, and you didn't know there was an argument.
grid-template-columns: 200px 1fr 1fr; /* fixed, then split the rest */
grid-template-columns: minmax(0, 1fr) 1fr; /* the overflow fix */grid-template-columns: 200px 1fr 1fr; /* fixed, then split the rest */
grid-template-columns: minmax(0, 1fr) 1fr; /* the overflow fix */minmax(0, 1fr) is just 1fr with the floor set to 0, so the track can shrink. You'll see it all over other people's grid code, and before I understood the auto minimum it looked like superstition. It isn't. It's the direct fix for a horizontal scrollbar you didn't ask for.minmax() and repeat()
minmax(min, max) gives a track a floor and a ceiling. minmax(200px, 1fr) reads as: never narrower than 200px, otherwise take a share of what's left. That one function is most of what makes grid responsive without media queries, as the next section shows.
repeat(3, 1fr) is shorthand for 1fr 1fr 1fr. It takes a count, or the more interesting option, the keywords auto-fill and auto-fit, which hand the count to the browser. Then there are the content-based ones: min-content is about as narrow as the longest word, max-content is how wide it would be with no wrapping at all, and auto acts like max-content but capped by the space available. Flipping between them in the demo is what made floors and ceilings click for me. Reading the definitions hadn't.
The Bug You Can Ship for Years
These two are where I expected a clear difference and found a sneaky one instead. In the docs auto-fill and auto-fit sound nearly the same, and in most examples you'll copy they behave the same. That's not a coincidence, it's how the difference stays hidden.
Both tell the browser to fit as many tracks as it can. They differ on exactly one thing: what happens to the tracks that end up empty.
auto-fillkeeps the empty tracks. They sit there at full width, holding space.auto-fitcollapses empty tracks to zero width, and the items that are left stretch to fill the gap.
The difference only shows up when you run out of items, which is what the demo lets you force. Drop the item count below the track count and the two split apart straight away.
So which one do you want? Use auto-fit for a gallery or card list that should always look full-width. Use auto-fill when keeping a steady column rhythm matters more than filling the row, like a dashboard where one lonely card should stay card-sized instead of stretching across the whole screen.
Lines, -1, and the Trap Under It
Grid numbers the lines between tracks, not the tracks. Three columns means four vertical lines, numbered 1 to 4 from the left. This catches everyone once and it definitely caught me: grid-column: 1 / 3 doesn't mean "columns 1 through 3." It means "line 1 to line 3," which is two columns wide.
grid-column: 1 / 3; /* line 1 to line 3 = 2 tracks wide */
grid-column: 1 / span 2; /* same thing, stated as a width */
grid-column: span 2; /* 2 tracks wide, auto-placed */
grid-column: 1 / -1; /* line 1 to the LAST line: full width */grid-column: 1 / 3; /* line 1 to line 3 = 2 tracks wide */
grid-column: 1 / span 2; /* same thing, stated as a width */
grid-column: span 2; /* 2 tracks wide, auto-placed */
grid-column: 1 / -1; /* line 1 to the LAST line: full width */Negative numbers count from the end, which makes -1 really handy: 1 / -1 spans the full width no matter how many columns there are. It looks like the answer to every full-bleed problem. Then there's a case where it quietly isn't, and that one took me a while to spot.
-1 only works against the explicit grid. If your columns came from auto-fill, the browser doesn't know where the last line is yet, so 1 / -1 quietly falls back to a single column. No error, no warning. Just a full-bleed banner that's suddenly one card wide.Named Lines: The Repair
Square brackets in a track list name a line, not a track. They don't add any size, they're just labels.
.gallery {
grid-template-columns:
[full-start] repeat(auto-fill, minmax(280px, 1fr)) [full-end];
}
.featured {
grid-column: full-start / full-end;
}.gallery {
grid-template-columns:
[full-start] repeat(auto-fill, minmax(280px, 1fr)) [full-end];
}
.featured {
grid-column: full-start / full-end;
}Notice where the names sit: outside the repeat(). However many columns auto-fill ends up making, full-start is still the far-left line and full-end is still the far-right one. You can't do that with numbers, because you don't know the count.
This is the line I'd already copied into my own gallery without understanding it, and it's what sent me off to write this. Wanting a full-width item inside an auto-fill grid is a completely normal thing to want, and grid-column: 1 / -1, the obvious answer, is exactly the thing that doesn't work there. Named lines aren't a style choice in that situation. They're the only way to say it.
Drawing the Layout
If layout-in ever needed one piece of evidence, it's grid-template-areas. You draw the layout as ASCII art. Each string is a row, each word is a named cell, and the children never find out where they are.
.page {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
grid-template-columns: 220px 1fr;
grid-template-rows: auto 1fr auto;
}
.page > header { grid-area: header; }.page {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
grid-template-columns: 220px 1fr;
grid-template-rows: auto 1fr auto;
}
.page > header { grid-area: header; }Two rules that are easy to trip over. Every row string needs the same number of columns, and an area has to be a solid rectangle. An L-shape is invalid, and when it's invalid the whole declaration gets dropped silently. That's the part worth remembering, because a typo here doesn't warn you, it just leaves you with a layout that stopped existing. Use . for a cell you want empty on purpose.
The real payoff is responsive work. Redraw the map in a media query and the whole layout rearranges without touching a single child rule:
@media (max-width: 700px) {
.page {
grid-template-areas: "header" "main" "sidebar" "footer";
grid-template-columns: 1fr;
}
}@media (max-width: 700px) {
.page {
grid-template-areas: "header" "main" "sidebar" "footer";
grid-template-columns: 1fr;
}
}dense Packing and Its Hidden Cost
Auto-placement walks items in DOM order, filling row by row. When a spanning item doesn't fit in what's left at the end of a row, the browser skips ahead and leaves a hole. grid-auto-flow: dense changes that: if a later, smaller item would fit in an earlier hole, it gets pulled back to fill it. The gaps disappear and the grid looks tidy.
This is the one I'd have switched on without thinking, because it reads like a free win. The cost is real and easy to miss: visual order stops matching DOM order. Keyboard focus follows the DOM, so someone tabbing through watches focus jump around the screen, and a screen reader reads out an order that doesn't match what's on screen. Nothing in the CSS warns you. It looks perfect right up until someone tabs through it.
dense for decorative galleries where the order doesn't mean anything. Never for anything ordered: search results, a numbered list, a feed, steps in a process.Turn on the DOM order numbers in the demo and toggle dense. Watching the numbers scramble is the whole argument.
Six Properties, Two Questions
Alignment gets presented as six properties to memorise, and that framing nearly made me skip the section. It's really two questions crossed with three scopes, and once you see it that way there's nothing to memorise.
Which axis? justify-* is the inline axis, horizontal in English. align-* is the block axis, vertical. This is backwards from the flexbox habit, where the axes swap depending on flex-direction. In grid they stay put, which is one less thing to keep track of.
What moves? *-items moves the content inside every cell. *-self overrides that for one item. *-content moves the whole set of tracks inside the container, and it only does anything when your tracks are smaller than the container. That's why it looks broken on a grid of 1fr tracks that already fill the space. It caught me in the demo below before I worked out what it was for.
| Property | Axis | Moves | Set on |
|---|---|---|---|
| justify-items | inline | content within each cell | container |
| align-items | block | content within each cell | container |
| justify-self | inline | one item in its cell | item |
| align-self | block | one item in its cell | item |
| justify-content | inline | the whole track grid | container |
| align-content | block | the whole track grid | container |
Two questions, three scopes. Not six unrelated properties.
place-items, place-self and place-content are shorthands that take align then justify. So place-items: center is the entire centering problem in one line.
gap
gap is row-gap and column-gap together. It only applies between tracks, never on the outer edges, which is exactly why it beats margins here. No first-child or last-child exception to write. Percentage gaps resolve against the container's own size.
What I Do Not Know Yet
I wrote this while learning it, not after mastering it, so take the confident bits with a pinch of salt. I haven't lived with a grid layout long enough to know what it costs later, when the design changes, when the content turns out to be messy, when someone else has to edit it. That part's still ahead of me.
I also haven't shipped subgrid yet. It lets a nested grid borrow its parent's track lines instead of making its own. The usual example is a row of cards where every title, body and footer should line up across cards even though the text lengths don't match. Before subgrid that needed fixed heights or JavaScript. It's been available in the major browsers since late 2023, but check your own support floor rather than taking my word for it.
What did change is the question I ask before writing any layout. Not "is this one-dimensional or two-dimensional," but who should decide how big this is, the content or the container? Flexbox was never the wrong tool. It was just the only tool I had, so every problem turned into a content-out problem. Having the other half is the actual difference.
And I've got a gallery to finish. It's still sitting there half-built, except now I can read my own stylesheet and say what each line is doing, which was the whole point of stopping to write this. The build itself is its own write-up, once it's actually done.
Further Reading
I'm learning this, not authoritative on it, so here's where I checked myself and where you should check me. Browser-support claims especially, they go stale faster than anything else in CSS.
- MDN โ CSS grid layout โ the one I keep open while writing this, especially the guides on line-based placement and auto-placement.
- CSS Grid Layout Module Level 2 (W3C) โ the spec, and the authority on track sizing and why
1frhas anautominimum. - MDN โ minmax() โ the details behind the
minmax(0, 1fr)fix. - MDN โ subgrid โ current Baseline status, which is the number to trust over any blog post including this one.
- Can I use โ CSS Grid โ for checking support against your own analytics rather than a general claim.