Mert Özen Further With Every Line
Frontend CSS3: Flexbox, Grid and Modern CSS — #06

CSS Grid 1: Tracks, fr, repeat(auto-fill, minmax()) and Named Grid Areas

Mert Özen Nov 14, 2026 11 min 2 views
CSS Grid 1: Tracks, fr, repeat(auto-fill, minmax()) and Named Grid Areas

repeat(auto-fill, minmax(14rem, 1fr)) builds a responsive grid without media queries by creating as many columns as fit the container; auto-fit collapses the empty columns to zero and stretches the items. We build Bookstore's catalog grid and page skeleton with Grid, and measure in Chromium and WebKit the difference between fr and minmax(0, 1fr), the implicit grid, named lines, grid-template-areas and how dense breaks the DOM order.

Version note: This post follows the CSS Grid Layout Level 2 specification and the state of Baseline in November 2026; the examples were run in Chrome 158 and WebKit 27.2 via Playwright 1.65, and Firefox support is taken from web-features data.

Guess First

We are building the catalog grid of Bookstore, the online bookstore used as the shared example across this site's series, with Grid. A search returned only two books: Dune and Emma. The list is 992px wide, and we are trying two different column definitions; the only difference between them is auto-fill versus auto-fit:

/* self-contained: this lab does not load main.css */
body { margin: 0; }
ul { list-style: none; margin: 0; padding: 0; }
.fr-demo { display: grid; grid-template-columns: 200px 1fr 2fr; inline-size: 800px; }
.results { display: grid; gap: 16px; inline-size: 992px; }
.results.fill { grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); }
.results.fit { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
.downloads { display: grid; gap: 16px; inline-size: 600px; }
.downloads.fr { grid-template-columns: 1fr 1fr; }
.downloads.zero { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.fr-demo p, .downloads p { margin: 0; }

How many pixels wide are the cards in each list? Both definitions say "as many columns of at least 14rem as fit"; is the result the same?

In the previous post we hit a limit of the catalog list we built with Flexbox: at 768px the card left alone on the last line stretched across the whole line, because Flexbox calculates each line separately. Making the cards sit in the same columns on every row is a two-dimensional problem, and in this post we solve it with Grid. Our topics are tracks, the fr unit, a responsive grid without media queries, the implicit grid, lines, and grid-template-areas, which describes the page skeleton by name. Subgrid and alignment are in the series' next post.

Tracks and the fr Unit

Grid divides the container into a grid of rows and columns. Each row or column is a track, and the lines between them are grid lines. grid-template-columns defines the column tracks, grid-template-rows the row tracks. fr is a unit specific to Grid: a share of the space left after fixed-size tracks have been placed. In the first example on the lab page I gave an 800px container 200px 1fr 2fr:

<div class="fr-demo" id="fr"><p>filters</p><p>results</p><p>details</p></div>

<ul class="results fill" id="fill"><li>Dune</li><li>Emma</li></ul>
node scripts/06-grid.mjs
node scripts/06-catalog.mjs
--- chromium
#fr: columns 200px 200px 400px
  filters                   x=0    y=0    w=200.0
  results                   x=200  y=0    w=200.0
  details                   x=400  y=0    w=400.0
#fill: columns 236px 236px 236px 236px
  Dune                      x=0    y=0    w=236.0
  Emma                      x=252  y=0    w=236.0
#fit: columns 488px 488px 0px 0px
  Dune                      x=0    y=0    w=488.0
  Emma                      x=504  y=0    w=488.0
#downloads-fr: columns 402.625px 181.375px
  https://downloads.bookst  x=0    y=0    w=402.6
  Emma, EPUB                x=419  y=0    w=181.4
#downloads-zero: columns 292px 292px
  https://downloads.bookst  x=0    y=0    w=292.0
  Emma, EPUB                x=308  y=0    w=292.0
#sparse: columns [full-start] 100px 100px 100px 100px [full-end]
  Dune                      x=0    y=0    w=100.0
  Ulysses                   x=110  y=0    w=100.0
  Emma                      x=0    y=90   w=320.0
  Middlemarch               x=330  y=90   w=100.0
  Summer sale               x=0    y=180  w=430.0
#dense: columns [full-start] 100px 100px 100px 100px [full-end]
  Dune                      x=0    y=0    w=100.0
  Ulysses                   x=110  y=0    w=100.0
  Emma                      x=0    y=90   w=320.0
  Middlemarch               x=220  y=0    w=100.0
  Summer sale               x=0    y=180  w=430.0
--- webkit
#fr: columns 200px 200px 400px
  filters                   x=0    y=0    w=200.0
  results                   x=200  y=0    w=200.0
  details                   x=400  y=0    w=400.0
#fill: columns 236px 236px 236px 236px
  Dune                      x=0    y=0    w=236.0
  Emma                      x=252  y=0    w=236.0
#fit: columns 488px 488px 0px 0px
  Dune                      x=0    y=0    w=488.0
  Emma                      x=504  y=0    w=488.0
#downloads-fr: columns 402.625px 181.375px
  https://downloads.bookst  x=0    y=0    w=402.6
  Emma, EPUB                x=419  y=0    w=181.4
#downloads-zero: columns 292px 292px
  https://downloads.bookst  x=0    y=0    w=292.0
  Emma, EPUB                x=308  y=0    w=292.0
#sparse: columns [full-start] 100px 100px 100px 100px [full-end]
  Dune                      x=0    y=0    w=100.0
  Ulysses                   x=110  y=0    w=100.0
  Emma                      x=0    y=90   w=320.0
  Middlemarch               x=330  y=90   w=100.0
  Summer sale               x=0    y=180  w=430.0
#dense: columns [full-start] 100px 100px 100px 100px [full-end]
  Dune                      x=0    y=0    w=100.0
  Ulysses                   x=110  y=0    w=100.0
  Emma                      x=0    y=90   w=320.0
  Middlemarch               x=220  y=0    w=100.0
  Summer sale               x=0    y=180  w=430.0

The first block: the fixed 200px column came out of the 800px, and the remaining 600px was split into three shares; 1fr became 200px and 2fr 400px. The difference from %: fr is calculated from the space left after gaps and fixed tracks are subtracted. Columns written in percentages overflow the container once a gap is added; columns in fr do not.

A Responsive Grid Without Media Queries: repeat(auto-fill, minmax())

The heart of the catalog grid is a single line: repeat(auto-fill, minmax(14rem, 1fr)). Let's read it piece by piece. minmax(14rem, 1fr) says a track is at least 14rem and at most one share. repeat(auto-fill, …) creates as many of those tracks as fit in the container. The result: as the container gets wider the number of columns grows on its own, and each column shares the remaining space equally. No media queries.

The answer to the opening question is in the second and third blocks. Four 14rem columns, 224px each, fit into 992px with 16px gaps. With auto-fill, all four columns were created and each is 236px; the two books took the first two columns, and the last two columns are empty but still take up space. With auto-fit, the empty tracks collapsed to 0px, as 488px 488px 0px 0px shows, and the two cards stretched to 488px. The difference only appears when there are fewer items than columns. In a catalog you don't want cards to swell depending on the number of results, so I chose auto-fill for the catalog. Where a few items should fill the row, for example a checkout progress indicator with two or three steps, auto-fit is a better fit.

I moved Bookstore's catalog list from Flexbox to Grid:

.book-list {
    display: grid;
    /* as many 14rem+ columns as fit; min() keeps one column from overflowing a tiny screen */
    grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr));
    gap: var(--space);
    list-style: none;
    margin: 0;
    padding: 0;
    /* z-index values inside the list never compete with the sticky header */
    isolation: isolate;
}

The flex: 1 1 14rem on .book-card from the previous post is no longer needed; the grid decides the card's width. The reason I wrote min(14rem, 100%) instead of 14rem: if the container is narrower than 14rem, say a 200px side panel, even a single column would try to be 14rem and overflow; min() brings the column down to the container's width in that case.

A Page Skeleton with grid-template-areas

I built the skeleton of the catalog page with Grid as well: header, filters, results and footer. grid-template-areas lets you describe the layout by names, almost like a drawing. Each quoted string is a row and each word is a cell; writing the same name in several cells widens that area:

/* page skeleton: one column on phones, filters next to the results from 48rem */
.page {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto 1fr auto;
    grid-template-areas:
        "header"
        "filters"
        "main"
        "footer";
    min-block-size: 100svh;
}

@media (width >= 48rem) {
    .page {
        grid-template-columns: 14rem minmax(0, 1fr);
        grid-template-rows: auto 1fr auto;
        grid-template-areas:
            "header  header"
            "filters main"
            "footer  footer";
    }
}

.site-header {
    grid-area: header;
}

.filters {
    grid-area: filters;
    padding-inline: var(--space);
}

.catalog {
    grid-area: main;
}

.site-footer {
    grid-area: footer;
    padding-inline: var(--space);
    border-block-start: 1px solid var(--color-border);
    color: var(--color-muted);
}
--- chromium
1280px areas: filters x=0 y=56 w=224 | main x=224 y=56 w=1056
  book-list columns: 244px 244px 244px 244px
  The Hobbit: x=1020 y=177 w=244
1024px areas: filters x=0 y=56 w=224 | main x=224 y=56 w=800
  book-list columns: 245.328px 245.328px 245.328px
  The Hobbit: x=240 y=496 w=245
768px areas: filters x=0 y=56 w=224 | main x=224 y=56 w=544
  book-list columns: 248px 248px
  The Hobbit: x=504 y=496 w=248
360px areas: filters x=0 y=56 w=360 | main x=0 y=213 w=360
  book-list columns: 328px
  The Hobbit: x=16 y=1106 w=328
--- webkit
1280px areas: filters x=0 y=56 w=224 | main x=224 y=56 w=1056
  book-list columns: 244px 244px 244px 244px
  The Hobbit: x=1020 y=178 w=244
1024px areas: filters x=0 y=56 w=224 | main x=224 y=56 w=800
  book-list columns: 245.328125px 245.328125px 245.34375px
  The Hobbit: x=240 y=497 w=245
768px areas: filters x=0 y=56 w=224 | main x=224 y=56 w=544
  book-list columns: 248px 248px
  The Hobbit: x=504 y=497 w=248
360px areas: filters x=0 y=56 w=360 | main x=0 y=213 w=360
  book-list columns: 328px
  The Hobbit: x=16 y=1107 w=328

On a phone, at 360px, everything is stacked in one column: the filters start at pixel 56, and the results come below them at pixel 213. At 48rem and above, the skeleton switches to two columns: the filters in the 224px left column, the results next to them. @media (width >= 48rem) is the new range syntax for media queries; it has been Baseline since March 2023, and the details are in the series' post on responsive design. The layout changes, but I did not touch the HTML; the grid-area name alone decides where each element goes.

The same output also shows how the catalog grid behaves. At 1280px the results area is 1056px with four columns; three at 1024px, two at 768px, one at 360px. The problem from the previous post is solved too: The Hobbit, which drops to the second row at 1024px, is as wide as the cards above it, 245px, and sits in the same column; it did not stretch across the row. Columns are now defined for the whole grid, independent of rows.

Rearranging the areas by screen size is also possible. For example, to show the results first and the filters after them on a phone, changing grid-template-areas is enough:

/* not in the project: results first on phones, filters after them */
.page {
    grid-template-areas:
        "header"
        "main"
        "filters"
        "footer";
}

I did not put this into the project, because it separates the visual order from the DOM order. In the DOM the filters come before the results; the Tab key and screen readers start with the filters, but on screen the results are on top. It is the same problem as order in Flexbox. If you want the results first on phones, change the order in the HTML and start the filters collapsed inside a details element; use Grid only for the visual arrangement.

Explicit and Implicit Grids, Lines and dense

The tracks you define with grid-template-columns and grid-template-rows form the explicit grid. If items do not fit into it, the browser adds new tracks; these form the implicit grid, and their size is set by grid-auto-rows and grid-auto-columns. I defined no rows at all in the catalog grid: every row is implicit and sized by its content. The second lab page has a four-column shelf, fixed-height implicit rows, a featured book that spans three columns and a campaign strip that covers the whole row:

/* self-contained: this lab does not load main.css */
body { margin: 0; }
ul { list-style: none; margin: 0; padding: 0; }
.shelf {
    display: grid;
    grid-template-columns: [full-start] repeat(4, 100px) [full-end];
    grid-auto-rows: 80px;
    gap: 10px;
    inline-size: 430px;
}
.shelf.dense { grid-auto-flow: dense; }
.featured { grid-column: span 3; }
.promo { grid-column: full-start / full-end; }
<ul class="shelf" id="sparse">
    <li>Dune</li><li>Ulysses</li><li class="featured">Emma</li><li>Middlemarch</li><li class="promo">Summer sale</li>
</ul>

Lines are numbered starting from 1, and negative numbers count from the end; grid-column: 1 / -1 means "from the first line to the last". You can also name lines: with [full-start] and [full-end], the campaign strip covers all columns by saying grid-column: full-start / full-end. Even if the number of columns changes later, the named lines point to the right place. span 3, on the other hand, leaves the start to the browser and says "cover three columns".

The last two blocks of the output show the placement algorithm. In #sparse, Dune and Ulysses took the first two columns of the first row. Emma needs three columns, but only two were left in the first row; Emma moved down to the second row, at y=90, and the last two cells of the first row stayed empty. Middlemarch was placed after Emma, in the last cell of the second row, at x=330. By default the browser does not go back to fill gaps. With grid-auto-flow: dense it does: in #dense, Middlemarch went back to the gap in the first row, at x=220, y=0. The grid is tighter, but the order on screen is now Dune, Ulysses, Middlemarch, Emma, while the DOM order is Dune, Ulysses, Emma, Middlemarch. Use dense only in purely visual galleries where the order carries no meaning.

fr versus minmax(0, 1fr): Overflowing Content

Now the most common Grid pitfall. 1fr is really shorthand for minmax(auto, 1fr), and that auto minimum resembles the min-width: auto of flex items from the previous post: the track does not get narrower than the min-content width of its content. On the lab page I put an e-book download URL into one of two equal columns. In the #downloads-fr block of the output the columns are not equal: 402.625px and 181.375px. The longest unbreakable part of the long URL widened the first column, and the second shrank. With minmax(0, 1fr), in #downloads-zero, both columns became 292px: the minimum is now zero, and the content cannot grow the track.

That is why I also wrote the main column of the page skeleton as minmax(0, 1fr) instead of 1fr. If the results area contains a wide table or a long line of code, 1fr makes the whole page scroll horizontally; minmax(0, 1fr) keeps the overflow inside that element. A detail: preventing the track from growing does not prevent the content from overflowing its cell. For the long URL to wrap inside the cell, give it overflow-wrap: anywhere.

In Production

Grid has been Baseline since 2017, and everything in this post, fr, repeat(), minmax(), auto-fill and auto-fit, areas and dense, is part of that first version; you don't need to think about browser support. The only newer thing I used is the media query range syntax, from March 2023. The part of Grid that is still not Baseline today is masonry layout; its status in November 2026 is in the series' next post.

Test the grid with real data: one result, two results, a page full of results, the longest title and the narrowest screen. A check like the measurement script in this post, which reads the number of columns and the position of a card at a few widths, tells you whether a CSS change broke the grid without setting up a visual comparison tool. In the Elements panel of Chrome DevTools, clicking the "grid" badge next to a grid container draws the line numbers, area names and track sizes over the page; it is the first place to look when hunting a Grid bug.

Common Pitfalls

First, assuming 1fr always gives equal columns. If the content is large, a 1fr column grows; if you want equal columns, write minmax(0, 1fr).

Second, mixing up auto-fill and auto-fit. The difference only shows when the items do not fill the row, so it is usually noticed in production, on a search that returns few results.

Third, separating the visual order from the DOM order. Moving areas with grid-template-areas, moving items with grid-row or grid-column, and dense all lead to the same result: the Tab order does not follow the order on screen.

Fourth, using a gap with percentage columns. grid-template-columns: 50% 50% with gap: 1rem overflows the container by 1rem; 1fr 1fr does not.

A Small Experiment

★☆☆ Add gap: 20px to .fr-demo in lab/06-tracks.html, calculate the column widths first, then check them with node scripts/06-grid.mjs. Try the same with 200px 25% 50%.

★★☆ Change auto-fill to auto-fit in the catalog grid and delete two cards from catalog.html. Compare the columns in the 1280px line of node scripts/06-catalog.mjs.

★★★ Build a two-column layout for the book detail page with grid-template-areas: the cover on the left, the title, author, price and button on the right, and a full-width summary below. It should drop to a single column on phones. Verify with a Playwright script that adding a long breadcrumb or a wide table does not make the page scroll horizontally.

In the next post we move on to the second part of Grid: subgrid, which lines up rows across cards with titles of different lengths, Grid's alignment properties, and the decision rule for "Grid or Flexbox?".