skip to content

In a three-column CSS Grid, an auto-placed item that declares grid-column: span 2 leaves an empty cell behind it. Why does the default auto-placement produce that hole?

level: middleimportance: must knowfreq 58%

answer

  1. it is not a bug, it is the algorithm
  2. one pointer, one direction
  3. forward-only search from the cursor
  4. sparse packing is the default mode
  5. the skipped cell is never revisited

basics

~20 s

The auto-placement cursor never moves backwards in the default sparse packing mode. When the wide item does not fit in the columns remaining on the current row, the cursor jumps to the next row and the skipped cells are left empty forever.

solid answer

~40 s

Grid's auto-placement keeps a cursor that only ever moves forward. For each auto-placed item it searches from the cursor's current position for the next empty area large enough, places the item there, and leaves the cursor just past it — it never rewinds to reconsider cells it has already passed. So if the cursor is sitting in column 3 of a three-column grid and the next item declares `grid-column: span 2`, there is no run of two free columns left on that row; the item starts on the next row and column 3 above it stays empty. That forward-only behaviour is the default packing mode, called *sparse*. Adding `dense` to `grid-auto-flow` is what lets later, smaller items go back and fill those holes.

code

html · 11 lines
html
<style>
  .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
  .grid > div { background: #cde; padding: 1rem; }
  .wide { grid-column: span 2; }
</style>
<div class="grid">
  <div>A</div>
  <div>B</div>
  <div class="wide">C</div>
  <div>D</div>
</div>

go deeper

for a junior

Recognise the shape of the problem: a spanning item that does not fit the columns left on a row moves down and leaves the cells behind it empty. Be able to point at the span and the column count as the two numbers involved.

for a middle

Explain the placement cursor and the sparse packing rule — search forward from the current position, never rewind — and walk through a concrete item-by-item trace to show exactly which cell gets stranded.

for a senior

Show judgment about the fix: adjust the track count so spans divide evenly, or reorder the DOM, before reaching for dense. Be able to say why a cosmetic hole is often the cheaper defect.

for a principal

Own the rule for the codebase: spans and track counts are a contract, and layouts whose span sizes do not divide the column count will strand cells at some breakpoint. Decide whether the design system encodes span-safe track counts or accepts holes.

## The cursor and the two packing modes CSS Grid places auto-positioned items with a **placement cursor**: a row/column pointer that starts at the first cell of the grid and walks forward. `grid-auto-flow` controls two independent things at once — the *direction* of the walk (`row`, the initial value, or `column`) and the *packing mode* (**sparse** by default, or **dense** when you add the `dense` keyword). Sparse packing has one defining rule: **the cursor never moves backwards.** For each auto-placed item, in document order, the algorithm starts looking from where the cursor currently is, finds the first empty area that fits the item, places it, and advances the cursor past it. Cells the cursor has already walked past are out of the running permanently — even if they are empty and a later item would fit. ## Walking the failing case ```html <div class="grid"> <div>A</div> <div>B</div> <div class="wide">C</div> <div>D</div> </div> ``` ```css .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .wide { grid-column: span 2; } ``` - **A** → row 1, column 1. Cursor moves to row 1, column 2. - **B** → row 1, column 2. Cursor moves to row 1, column 3. - **C** needs two adjacent free columns. Only column 3 is left on row 1, so there is no fit. The cursor moves to the start of row 2, and **C** takes columns 1–2 of row 2. Row 1 column 3 is now stranded. - **D** → row 2, column 3. The result is a visible hole at the end of row 1, with the wide item pushed down. This is the single most common "my grid has a gap in it" question, and the answer is never "a bug" — it is the cursor rule doing exactly what it is specified to do. ## Why sparse is the default Sparse packing guarantees that **visual order matches document order**. Because the cursor only moves forward, item *n+1* is always at or after item *n* in the reading direction. That means the order a sighted user reads the grid, the order the accessibility tree exposes, and the order the Tab key visits interactive children all agree. A hole is a cosmetic cost; losing that agreement is a correctness cost, so the spec makes the safe behaviour the default. ## The forward-only rule is more precise than "next empty cell" The rule also covers items that fix one axis but not the other. In row flow, if an item declares a definite column start (say `grid-column: 3`) but no row, the algorithm sets the cursor's column to 3; if that is *earlier* than where the cursor already was, the cursor's row is incremented by one so it still never goes backwards. That is why interleaving half-placed items with fully auto-placed ones can push content further down than you expect. ## The four ways to remove the hole 1. **Change the tracks so the span fits the rhythm.** A four-column grid with `span 2` items divides evenly; a three-column grid with `span 2` items will always strand a cell on alternating rows. Most of the time the real fix is arithmetic, not a new property. 2. **Reorder the markup** so the wide item lands where two columns are free. This keeps sparse packing and keeps reading order truthful, because you changed the actual order. 3. **Opt into `grid-auto-flow: row dense`.** Later, narrower items are then allowed to backfill the hole — at the price of visual order diverging from DOM order. 4. **Make the span conditional.** Because the span lives on the item, you can drop it at narrow viewports (`grid-column: auto`) so the item stops needing two columns at all. ## Related mechanics worth naming A span never causes an item to be *shrunk* to fit. If an auto-placed item asks for more columns than the grid has, grid widens the implicit grid rather than clamping the span. And the hole itself is a real, empty grid area: it participates in `gap` and in track sizing exactly as an occupied cell would, so a tall neighbour can still stretch the row that contains it. ## What an interviewer is listening for The phrase that earns the point is "**the cursor never moves backwards in sparse packing**". Candidates who only say "grid leaves gaps sometimes, use dense" have memorised the workaround without the model, and they get caught by the follow-up: *does dense fix reading order too?*

  • Does adding grid-auto-flow: dense also change the order screen readers announce the items in?
    No. `dense` only changes which cells the algorithm is allowed to consider, so it changes the visual arrangement. The accessibility tree and the Tab order still follow DOM order, so a backfilled item is read in its original position while appearing somewhere earlier on screen. That mismatch is the reason `dense` is opt-in rather than the default.
  • If an auto-placed item declares a definite column but no row, can the cursor still move backwards?
    No — the rule holds. The algorithm sets the cursor's column to the item's column-start line, and if that line is earlier than the cursor's current column it increments the cursor's row by one. So the cursor moves down rather than back, which is why mixing half-placed items into an auto-placed sequence can push later content onto rows you did not expect.
  • Would switching to grid-auto-flow: column remove the hole in this example?
    It moves it rather than removing it. Column flow makes the cursor walk down each column before moving right, and the same forward-only rule applies to row spans instead: an item with `grid-row: span 2` that does not fit in the rows left in the current column will strand a cell there. The mismatch between span size and track count is the real cause.

saying these in an interview costs you the question

  • Calls the gap a browser bug rather than the specified behaviour
  • Thinks grid shrinks a spanning item to fit the remaining columns
  • Believes later items automatically backfill holes without dense
  • Says dense also fixes the reading and focus order
  • Assumes the empty cell collapses and stops consuming gap

context