You drag the dashboard down to tablet width and a scrollbar appears along the
bottom. Every track in the stylesheet is minmax(16rem, 1fr). Nothing is
wider than you asked for.
But there’s a column on the right that you never wrote, holding one widget and a lot of empty space. And the panel that used to sit below the chart is now sitting above it, out of source order, and nobody on the team can say why.
Neither of those is a sizing bug. Grid resolves a layout in two phases: first it decides which cells every item occupies, growing the grid as it goes, then it works out how big the tracks have to be. The first phase finishes before anything on the page has a width. Most of the grid bugs that survive a round of pair debugging are phase-one bugs being investigated in phase two.
Placement Runs On Integers
The grid item placement algorithm reads grid-column, grid-row, and span,
and assigns every item a rectangle of cells. Track sizing runs afterwards and
works out how wide those columns end up. That order is fixed, and the first
step never asks about size — it can’t. When the browser decides your chart
occupies two columns, no track has a width, no item has been measured, and the
container’s own inline size may still be unresolved.
So a span is a count of cells, not a request for space. Grid has no mechanism for deciding that two columns won’t fit and settling for one. It made that promise on integers, and it keeps it.
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1rem;
}
.board__chart {
grid-column: span 2;
}
At desktop width, auto-fit produces four tracks and the chart takes two of
them. Narrow the container until only one track fits, and the chart still asks
for two. Grid does the only thing available to it: it appends an implicit
column to hold the other half of the span, sized by grid-auto-columns, which
defaults to auto. The board is now two tracks wide inside a container that
fits one.
Breaks
grid-column: span 2, unconditional. Below two-column width the span invents a column and the board overflows its container.
Holds
container-type: inline-size on the board, and the span applied only above the width where a second track exists.
The fix has to come from outside the placement algorithm, because placement has no opinion about width:
.board {
container-type: inline-size;
}
@container (width >= 34rem) {
.board__chart {
grid-column: span 2;
}
}
That’s a container query doing the arithmetic grid refuses to do — deciding, in pixels, whether the span is affordable. The track sizing you wrote was never wrong. Placement had already settled the column count before sizing got a vote.
The Four Passes, In Order
Placement isn’t one sweep through the children. It’s four passes, and each one can take cells the next one wanted.
| Pass | What it places | Where it surprises people |
|---|---|---|
| 1 | Items with both a row and a column pinned | Runs before every auto-placed sibling |
| 2 | Items with a row pinned, column auto | Each row keeps its own cursor |
| 3 | Nothing — it fixes the column count | Phantom columns are born here |
| 4 | Everything else, via the placement cursor | The only pass source order fully owns |
Pass one against pass four is the one that catches people. Give a widget
grid-area: 2 / 3 and it is placed before any of its siblings, including the
ones above it in the markup. Those siblings get auto-placed later, find the
cell occupied, and flow around it. Nothing reordered your DOM; one item simply
had its seat reserved in an earlier round.
Pass two has a wrinkle worth knowing. An item with a definite row and an
automatic column is placed along that row, after anything the same pass has
already put there. Two widgets locked to grid-row: 2 therefore land in
source order relative to each other, and in no particular relationship to
anything auto-placed.
Source order is the tiebreaker, not the rule.
— Layout_Doctrine // §4
Pass three is quieter and does more damage. Grid works out how many columns
the implicit grid needs: the explicit count, then anything with a definite
column position beyond that, then the largest column span among items whose
column position isn’t definite. That last clause is the auto-fit overflow
from the previous section, and it’s also this:
.board__notes {
grid-row: 2;
grid-column: span 3; /* on a two-column board */
}
The board grows a third column, occupied by one widget on one row. Everything else keeps to the first two, so the right-hand edge of the layout becomes a stripe of nothing. The stylesheet says two columns. The grid inspector says three, and the inspector is right.
One more input to all of this: auto-placement walks
order-modified document order
, so order genuinely changes which item gets which cell. It also changes paint
order and nothing else. Tab order, screen reader order, and browser find all
still follow the markup.
The Cursor Only Moves Forward
Pass four walks a cursor. It starts at the first cell and, for each remaining item, scans forward for a run of empty cells wide enough to hold that item’s span. Forward is the entire design. The cursor never goes back.
So when a wide item won’t fit in what’s left of the current row, the cursor abandons the rest of that row and starts the next one. Those abandoned cells are gone — nothing later in the source can reach backwards to claim them.
Fig. 02 — The three-wide item didn’t fit after column two, so the cursor wrapped. Those cells stay empty.
That hole is a guarantee, not a defect. Sparse packing is the reason visual order still matches source order once you start using spans, which is most of why grid is safe to hand to a component library.
grid-auto-flow: dense trades that guarantee away. The cursor restarts from
the beginning of the grid for each item, so a narrow widget further down the
markup drops into an earlier gap. The
beginner-level version of this warning
is that DOM order is unchanged. The advanced version is that there is no way
to make focus follow the visual order afterwards — not tabindex, not
aria-flowto, which no engine implements usefully. You either accept the
mismatch or you don’t use dense.
!
Dense is for tiles, not sequences. A gallery of independent cards is fine. Anything a keyboard user moves through in order — a form, a wizard, a ranked list — is not.
The alternative nobody suggests often enough is to stop producing holes. Holes
come from spans that don’t divide the column count, and with auto-fit you
never know the column count, so some width will always hole. A fixed track
count with spans that tile it holes nowhere, and costs you one container query
per breakpoint. That’s usually the cheaper trade for a dashboard.
Minus One Only Counts The Grid You Wrote
grid-column: 1 / -1 is the most-copied line in grid and the least
understood. Negative line numbers count back from the end edge of the
explicit grid — the tracks you declared with grid-template-columns and
grid-template-rows. Implicit tracks get lines too, but those lines have no
numbers at all. You cannot address them, in either direction.
Which produces this, a real layout that has shipped more than once:
.shell {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
grid-auto-rows: minmax(4rem, auto);
}
.shell__sidebar {
grid-row: 1 / -1; /* intended: full height */
}
No rows were declared, so the explicit row grid has zero tracks and exactly
one line. -1 resolves to that line, which is line 1, the same line the item
starts on. The sidebar occupies a single row and the layout looks like the
CSS was never applied. Nothing warns you, because nothing is invalid.
The durable fix is to name the edges you want to address, so the reference survives a change to the track count:
.shell {
display: grid;
grid-template-columns: [full-start] 16rem minmax(0, 1fr) [full-end];
grid-template-rows: [body-start] auto 1fr [body-end];
}
.shell__banner {
grid-column: full;
}
A bare <custom-ident> in grid-column resolves to the -start and -end
lines of that name, so grid-column: full means full-start / full-end. Add
a track in the middle next quarter and the banner still spans everything.
grid-template-areas gives you the same guarantee on both axes for free,
since naming an area defines those lines implicitly — and an item placed by
area name can never conjure an implicit track, because areas are part of the
explicit grid by definition.
This is also the thread that connects back to subgrid: a subgridded axis inherits the parent’s line names, so naming your lines once pays out at every level of nesting.
Debugging Phase One
When a grid does something structurally strange, stop reading track sizes. Reconstruct the placement instead.
- Open the grid inspector and count the tracks that exist. If there are more than you declared, you have a placement bug, and the extra track tells you which axis to look at.
- Read the computed
grid-column-startandgrid-row-starton the item that moved. Auto-placement resolves to concrete lines, and seeing3where you expected1ends the argument immediately. - Delete every explicit placement rule, confirm the auto-placed layout is sane, then add them back one at a time. Pass-one collisions only show up in combination.
- Replace raw line numbers with
span Nor named lines before you ship the fix, so the next person changing the track count doesn’t reopen this.
Worth doing the first two before you form a theory. The gap between the grid you declared and the grid that exists is the whole bug in about four cases out of five, and the inspector settles it faster than reasoning does.
Grid never shows you the middle step. It shows you what you wrote and then it shows you the result, and the art of debugging a hard layout is reconstructing the pass that ran in between.