CSS grid-auto-flow: how the browser places the items you did not place

grid-auto-flow decides the direction the browser fills the grid in, and whether it goes back to fill holes. grid-auto-rows and grid-auto-columns decide how big the extra tracks are.

grid-auto-flow tells the browser how to place grid items that have no grid-row or grid-column of their own. row (the default) fills across and adds rows. column fills downwards and adds columns. Adding dense lets later items go back and fill holes.

Try all four combinations. The numbers are the HTML order, and the text under the grid counts the tracks the browser had to add.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>grid-auto-flow lab: row, column, dense</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 10px; }
  button { font: 600 13px system-ui, sans-serif; padding: 7px 12px; border-radius: 8px; border: 1px solid #c9cdd4; background: #fff; cursor: pointer; }
  button[aria-pressed="true"] { background: #1d2330; color: #fff; border-color: #1d2330; }
  label { font-size: 13px; display: flex; gap: 5px; align-items: center; margin-left: 4px; }
  .grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));  /* 3 declared columns */
    grid-template-rows: repeat(3, 40px);                /* 3 declared rows */
    grid-auto-columns: minmax(0, 1fr);  /* size of any column the browser adds */
    grid-auto-rows: 40px;               /* size of any row the browser adds */
    grid-auto-flow: row;                /* the buttons switch this */
    gap: 6px; padding: 6px; border-radius: 10px;
    background: repeating-linear-gradient(45deg, #fde2da 0 6px, #fff7f5 6px 12px);  /* shows empty cells */
  }
  .grid > div {
    border-radius: 7px; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .15);
    display: grid; place-items: center; font-weight: 700; font-size: 17px;
  }
  .grid > .wide { grid-column: span 2; background: #dbeafe; }
  .grid > .tall { grid-row: span 2; background: #d6f2df; }
  .out { font-size: 13px; line-height: 1.55; margin-top: 10px; }
  code { font: 600 13px ui-monospace, Consolas, monospace; }
</style>
</head>
<body>
<div class="bar">
  <button id="row" aria-pressed="true">row</button>
  <button id="column" aria-pressed="false">column</button>
  <label><input type="checkbox" id="dense"> dense</label>
</div>

<!-- the numbers are the HTML order -->
<div class="grid" id="grid">
  <div>1</div><div>2</div><div class="wide">3</div><div>4</div><div class="tall">5</div>
  <div>6</div><div class="wide">7</div><div>8</div><div>9</div>
</div>

<div class="out">
  <div><code id="css"></code></div>
  <div id="tracks"></div>
  <div>Order on screen: <code id="order"></code></div>
</div>

<script>
  const grid = document.getElementById('grid');
  let dir = 'row';

  function update() {
    const flow = dir + (document.getElementById('dense').checked ? ' dense' : '');
    grid.style.gridAutoFlow = flow;
    document.getElementById('row').setAttribute('aria-pressed', dir === 'row');
    document.getElementById('column').setAttribute('aria-pressed', dir === 'column');
    document.getElementById('css').textContent = 'grid-auto-flow: ' + flow + ';';

    // the computed track list includes the tracks the browser added
    const cs = getComputedStyle(grid);
    const cols = cs.gridTemplateColumns.split(' ').length;
    const rows = cs.gridTemplateRows.split(' ').length;
    document.getElementById('tracks').textContent =
      'Columns: 3 declared + ' + (cols - 3) + ' added. Rows: 3 declared + ' + (rows - 3) + ' added.';

    // where the items landed: top to bottom, then left to right
    const seen = [...grid.children].sort((a, b) => a.offsetTop - b.offsetTop || a.offsetLeft - b.offsetLeft);
    document.getElementById('order').textContent = seen.map(d => d.textContent).join(' ');
  }

  document.getElementById('row').addEventListener('click', () => { dir = 'row'; update(); });
  document.getElementById('column').addEventListener('click', () => { dir = 'column'; update(); });
  document.getElementById('dense').addEventListener('change', update);
  update();
</script>
</body>
</html>
3 declared columns and 3 declared rows. Switch the direction and dense, and watch the added tracks and the order on screen.

The grid declares 3 columns and 3 rows. In row flow the columns stay at 3 and rows are added. In column flow the rows stay at 3 and columns are added. One property decides both.

row and column: the two directions

The browser keeps a cursor that walks through the grid cells in order. With row, it walks left to right, then drops to the next row. With column, it walks top to bottom, then moves to the next column.

Row flow fills across and adds rows. Column flow fills down and adds columns.
Row flow fills across and adds rows. Column flow fills down and adds columns.

Each direction needs the other axis declared:

/* row flow: fixed columns, rows added as needed */
.list { display: grid; grid-template-columns: repeat(3, 1fr); }

/* column flow: fixed rows, columns added as needed */
.list { display: grid; grid-template-rows: repeat(3, auto); grid-auto-flow: column; }

Column flow with no grid-template-rows gives a grid with one row. Every item then gets its own new column, and they all sit side by side. That is a bug in a list, and exactly what you want in a sideways strip, covered below.

An item that spans more tracks than the grid has also adds tracks. grid-column: span 4 in a 3-column grid makes a fourth column.

Implicit tracks and grid-auto-rows

Tracks you list in grid-template-columns and grid-template-rows are the explicit grid. Anything the browser adds to fit more items is implicit. Implicit rows take their size from grid-auto-rows, implicit columns from grid-auto-columns.

One declared row, three added ones. A list of sizes in grid-auto-rows repeats.
One declared row, three added ones. A list of sizes in grid-auto-rows repeats.

Both properties default to auto, which sizes each track to its content. For cards that usually means rows of uneven height. Common values:

Value What the added rows do
auto (default) Each row is as tall as its tallest item
120px Every added row is 120px, and longer content overflows
minmax(120px, auto) At least 120px, taller when the content needs it
60px 30px Sizes alternate: 60, 30, 60, 30
4px Tiny rows that items span many of, used for the masonry wall below

minmax(120px, auto) is the safe choice for cards: they line up, and a long title does not spill out. Declaring the column side is covered in CSS grid-template-columns.

grid-auto-columns: a row that scrolls sideways

Column flow plus grid-auto-columns gives a horizontal strip with no extra markup. Every card becomes a new implicit column, and grid-auto-columns sets how wide each one is.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A sideways row with grid-auto-flow: column</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
  button { font: 600 13px system-ui, sans-serif; padding: 7px 10px; border-radius: 8px; border: 1px solid #c9cdd4; background: #fff; cursor: pointer; }
  button[aria-pressed="true"] { background: #1d2330; color: #fff; border-color: #1d2330; }
  .strip {
    display: grid;
    grid-auto-flow: column;   /* each new card becomes a new column */
    grid-auto-columns: 45%;   /* width of every added column; the buttons switch this */
    gap: 10px;
    overflow-x: auto;         /* the row scrolls sideways instead of squeezing */
    scroll-snap-type: x mandatory;
    padding: 4px 2px 12px;
  }
  .card {
    scroll-snap-align: start;
    height: 150px; border-radius: 12px; padding: 12px; box-sizing: border-box;
    color: #fff; font-weight: 700; display: flex; align-items: flex-end;
  }
  .out { font-size: 13px; line-height: 1.5; margin: 8px 0 0; }
  code { font: 600 13px ui-monospace, Consolas, monospace; }
</style>
</head>
<body>
<div class="bar">
  <button data-size="45%" aria-pressed="true">45%</button>
  <button data-size="80%" aria-pressed="false">80%</button>
  <button data-size="160px" aria-pressed="false">160px</button>
  <button id="add">+ Add card</button>
</div>

<div class="strip" id="strip"></div>
<p class="out"><code id="css"></code><br><span id="count"></span></p>

<script>
  const strip = document.getElementById('strip');
  const colors = ['#2563eb', '#0f766e', '#9333ea', '#c2410c', '#be123c', '#4d7c0f'];

  function report() {
    const cols = getComputedStyle(strip).gridTemplateColumns.split(' ');
    document.getElementById('css').textContent = 'grid-auto-columns: ' + strip.style.gridAutoColumns + ';';
    document.getElementById('count').textContent =
      cols.length + ' columns, all added by the browser, each ' + Math.round(parseFloat(cols[0])) + 'px wide.';
  }

  function addCard() {
    const n = strip.children.length + 1;
    const card = document.createElement('div');
    card.className = 'card';
    card.style.background = 'linear-gradient(135deg, ' + colors[n % colors.length] + ', #1d2330)';
    card.textContent = 'Card ' + n;
    strip.appendChild(card);
    report();
  }

  document.querySelectorAll('[data-size]').forEach(btn => {
    btn.addEventListener('click', () => {
      strip.style.gridAutoColumns = btn.dataset.size;
      document.querySelectorAll('[data-size]').forEach(b => b.setAttribute('aria-pressed', b === btn));
      report();
    });
  });
  document.getElementById('add').addEventListener('click', addCard);

  strip.style.gridAutoColumns = '45%';
  for (let i = 0; i < 4; i++) addCard();
</script>
</body>
</html>
Every column here is implicit. Change grid-auto-columns and add cards; the strip scrolls instead of squeezing.
.strip {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 45%;   /* each card is 45% of the strip */
  gap: 10px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
.strip > * { scroll-snap-align: start; }

A percentage is measured against the strip, so the cards keep the same share of the screen on a phone and a laptop. A length such as 160px keeps the cards the same size and shows more of them on wider screens.

The snapping part is explained in CSS scroll snap.

dense: filling the holes

By default the cursor only moves forward. When an item spans 2 columns and only 1 cell is left in the current row, the item moves to the next row and the cell stays empty. Later items do not go back for it.

dense changes that. Before placing each item, the browser starts again from the top and takes the first spot where the item fits. Small items drop into holes that wide items left behind.

In the lab above, switch to row and tick dense. Item 4 moves up into the empty cell next to 2, and one added row disappears.

The cost is order. Items are no longer drawn in HTML order, but keyboard focus and screen readers still follow the HTML. Use dense for photos and tiles. Keep the default for steps, prices and anything read in sequence.

Placing single items by line number is covered in CSS grid row start and span.

A photo wall where each column stacks tiles of different heights with no gaps is often called masonry. With grid-auto-rows: auto, a short tile next to a tall one leaves a hole below it, because the whole row is as tall as the tall tile.

Tall rows leave holes under short tiles. 4px rows let each tile span exactly its own height.
Tall rows leave holes under short tiles. 4px rows let each tile span exactly its own height.

The fix is tiny rows. Set grid-auto-rows: 4px and row-gap: 0, then give every tile a row span that matches its height:

const row = 4;                                  // grid-auto-rows
for (const tile of wall.children) {
  const h = tile.offsetHeight + 10;             // tile + its 10px bottom margin
  tile.style.gridRowEnd = 'span ' + Math.ceil(h / row);
}

Wide tiles get grid-column: span 2, and grid-auto-flow: row dense lets later tiles fill the holes they leave.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Masonry-style gallery with grid-auto-rows, span and dense</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; margin-bottom: 10px; font-size: 13px; }
  label { display: flex; gap: 5px; align-items: center; }
  .wall {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
    grid-auto-rows: 4px;       /* tiny rows: each tile spans as many as its height needs */
    column-gap: 10px;
    row-gap: 0;                /* the space under a tile comes from its margin */
    grid-auto-flow: row dense; /* later tiles fill holes left by wide ones */
  }
  .tile { margin: 0 0 10px; border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .15); }
  .tile.wide { grid-column: span 2; }
  .pic { display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 20px; }
  .tile figcaption { padding: 7px 9px 9px; font-size: 12.5px; line-height: 1.35; }
  code { font: 600 12.5px ui-monospace, Consolas, monospace; }
</style>
</head>
<body>
<div class="bar">
  <label><input type="checkbox" id="dense" checked> dense</label>
  <span>Wall height: <code id="height"></code></span>
</div>

<div class="wall" id="wall"></div>

<script>
  const wall = document.getElementById('wall');
  // [picture height, caption, wide?] - sample tiles; the pictures are CSS gradients
  const tiles = [
    [90, 'Harbour at dawn'], [130, 'Stairwell'], [60, 'Two chairs', true],
    [70, 'Market stall'], [110, 'Rain on the window, a caption long enough to wrap'],
    [60, 'Bridge', true], [120, 'Lighthouse'], [80, 'Bicycles'], [60, 'Rooftops', true], [100, 'Cafe corner']
  ];
  tiles.forEach(([h, text, wide], i) => {
    const t = document.createElement('figure');
    t.className = 'tile' + (wide ? ' wide' : '');
    t.innerHTML = '<div class="pic" style="height:' + h + 'px;background:linear-gradient(160deg,hsl(' + (i * 37) +
      ' 60% 55%),hsl(' + (i * 37 + 40) + ' 55% 30%))">' + (i + 1) + '</div><figcaption>' + text + '</figcaption>';
    wall.appendChild(t);
  });

  // row span = (tile height + its 10px bottom margin) / row size, rounded up
  function layout() {
    const row = parseFloat(getComputedStyle(wall).gridAutoRows);
    for (const t of wall.children) {
      const h = t.querySelector('.pic').offsetHeight + t.querySelector('figcaption').offsetHeight + 10;
      t.style.gridRowEnd = 'span ' + Math.ceil(h / row);
    }
    document.getElementById('height').textContent = wall.offsetHeight + 'px';
  }

  document.getElementById('dense').addEventListener('change', (e) => {
    wall.style.gridAutoFlow = e.target.checked ? 'row dense' : 'row';
    layout();
  });
  new ResizeObserver(layout).observe(wall);  // captions rewrap when the width changes
  layout();
</script>
</body>
</html>
Untick dense and compare the wall height: holes next to the wide tiles stay empty. Resize the window and the spans are measured again.
  • Why margin, not gap: a row-gap sits between every pair of 4px rows, so a tile spanning 35 rows would carry 34 gaps. A bottom margin on the tile is counted once.
  • Measure again on resize: captions wrap to more lines on narrow screens. A ResizeObserver on the wall reruns the loop.
  • Images: a real <img> has no height until it loads. Set width and height attributes, or run the loop again on each image's load event.

If you need no wide tiles, CSS multi-column stacks tiles with no JavaScript at all. It fills the first column top to bottom, then the second, so the order runs down, not across.

For a plain grid of equal photos, see an HTML image gallery.

When it does not work

What you see Cause Fix
Column flow puts all items in one long row No grid-template-rows Declare the rows, e.g. repeat(3, auto)
Added rows are uneven or squashed grid-auto-rows is auto Set minmax(120px, auto) or a fixed size
Empty cells next to wide items The default flow never goes back Add dense if order does not matter
Tab order jumps around the screen dense changed the drawn order only Remove dense from grids of links or buttons
Extra narrow column appears A span larger than the column count Lower the span, or set grid-auto-columns
Masonry tiles overlap or leave strips Span measured before text or images loaded Measure again on resize and on image load
Masonry spacing is far too big row-gap counted between tiny rows Set row-gap: 0 and use a bottom margin

For how grids break onto new lines as the screen narrows, see CSS grid wrap. For the overall layout, start from CSS grid.

Auto-placement is easiest to judge by switching it on a real screen, at a real width. A screenshot shows one width only, and an .html file sent as an attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can tick dense and resize the wall themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is the default value of grid-auto-flow?

row. Items without a position fill the first row from left to right, then the next row, and the browser adds rows as needed. It never goes back to fill a hole it has passed.

What is the difference between grid-auto-flow: dense and row dense?

None. dense on its own means row dense, and browsers report the computed value as dense. column dense fills holes while filling column by column.

Why does grid-auto-flow: column put everything in one row?

Column flow fills each column from top to bottom before starting a new one, so it needs to know how many rows there are. With no grid-template-rows, the grid has one row, and every item gets its own new column. Declare the rows, for example grid-template-rows: repeat(3, auto).

Does dense change the tab order or what a screen reader reads?

No. dense only changes where items are drawn. Keyboard focus and screen readers still follow the HTML order, so a dense grid of links can make focus jump around the screen.

Can grid-auto-rows take more than one size?

Yes. grid-auto-rows: 60px 30px gives the first added row 60px, the second 30px, the third 60px again, and so on. The same works for grid-auto-columns.

Keep reading