ยท14 min readยทDeep Dive

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?

SituationReach forBecause
Nav bar, tag list, button rowflexboxitem widths come from their own text
Card gallery, image mosaicgridtracks should be uniform regardless of content
Page shell (header/sidebar/main)gridnamed areas, and rows relate to columns
Centering one thinggridplace-items: center
Inside a card (title, body, button)flexboxone direction, content-driven
Aligning across sibling cardsgrid + subgridflexbox 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.

two-grids.css
css
.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.

fr-unit.css
css
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 */
๐Ÿ’กThe fix everyone reaches for
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-fill keeps the empty tracks. They sit there at full width, holding space.
  • auto-fit collapses empty tracks to zero width, and the items that are left stretch to fill the gap.
โš ๏ธWhy this hides so well
If your items always fill every track, the two are identical. You can ship the wrong one for years and never find out, right up until a category page renders with two results instead of six.

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.

placement.css
css
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.

โš ๏ธThe trap
-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.

named-lines.css
css
.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.

areas.css
css
.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:

areas-responsive.css
css
@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.

๐ŸšซWhere it is safe
Use 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.

PropertyAxisMovesSet on
justify-itemsinlinecontent within each cellcontainer
align-itemsblockcontent within each cellcontainer
justify-selfinlineone item in its cellitem
align-selfblockone item in its cellitem
justify-contentinlinethe whole track gridcontainer
align-contentblockthe whole track gridcontainer

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.