Responsive Grids with repeat(auto-fit, minmax())
The narrow problem
You need a grid of cards that shows four across on a wide desktop, three on a laptop, two on a tablet, and one on a phone — and you would like to write that without four media queries, without picking arbitrary pixel thresholds, and without the layout breaking the day someone drops the grid into a narrow sidebar where the viewport width is a lie. One line of grid-template-columns does the whole job. The catch is that the line has four moving parts, two of them look almost identical (auto-fit and auto-fill), and the naive version overflows its parent on small screens. This page pins down each part. It is part of CSS Grid and Subgrid Layouts within Mastering Container Queries & Responsive Layouts.
Why track counting beats breakpoints
A media query breakpoint encodes an assumption: at this viewport width, this many columns look right. The assumption is wrong the moment the component moves. Put a three-column card grid in a sidebar and the viewport is still 1400px wide while the grid has 320px to work with, so it renders three squashed columns. Breakpoints also multiply — every new column count is another rule, and the set has to be re-tuned whenever the card's minimum comfortable width changes.
The repeat-auto form inverts the relationship. You declare the smallest width at which a card is still readable, and the grid derives the column count from the space actually available to it. There is exactly one number to tune, it has a real design meaning, and the result is correct in any container because the algorithm measures the container, not the window. This is intrinsically responsive layout in the sense used throughout Intrinsic Sizing Techniques: the sizes come from constraints and content rather than from a table of magic numbers.
It does not replace container queries — those remain the tool for changes the track algorithm cannot express, such as flipping a card's internal arrangement. The division of labour is clean: track counting decides how many cards share a row, a container query decides what a card looks like at its resulting width.
Reading the declaration part by part
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
repeat(auto-fit, …)— generate an unspecified number of tracks, as many as the container can hold given the gaps, then collapse any that ended up with no items in them.minmax(a, b)— each track has flooraand ceilingb. The floor drives how many tracks fit; the ceiling drives how leftover space is shared.min(100%, 20rem)— the floor is 20rem, except in a container narrower than 20rem, where it becomes the full container width. This is the guard, discussed below.1fr— the ceiling. Every track gets an equal share of the free space left after the floors are satisfied, so the row always fills edge to edge with equal columns.
The two keywords only diverge when the container is wide enough for more tracks than you have items. Below that point they are indistinguishable, which is why the difference goes unnoticed on the desktop layout you developed against and shows up as a strange gap on an ultrawide monitor or in a nearly-empty filtered list.
Drag the demo frame wide to reproduce it, then narrow to watch both rows reflow identically.
Complete working implementation
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Breakpoint-free card grid</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; color: #1c2430; background: #f4f6fb; }
.wrap { max-width: 72rem; margin-inline: auto; padding: 2rem 1rem; }
.grid {
display: grid;
/* The only responsive rule in this document.
min(100%, 18rem) keeps the floor from exceeding the container,
1fr shares the remainder equally, auto-fit removes empty tracks. */
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 1rem;
}
.card {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 0.5rem;
padding: 1rem;
background: #fff;
border: 1px solid #d4dbe8;
border-radius: 0.75rem;
/* Grid items default to a min-width of auto; releasing it lets a card
shrink below its longest word instead of overflowing the track. */
min-width: 0;
}
.card h3 { margin: 0; font-size: 1rem; }
.card p { margin: 0; font-size: 0.9rem; color: #5a6472; }
.card a { font-size: 0.85rem; }
/* A card that ends up alone on a wide row would stretch very wide.
Cap it so the grid never produces an unreadable line length. */
.grid { justify-content: start; }
.card { max-width: 34rem; }
</style>
</head>
<body>
<div class="wrap">
<div class="grid">
<article class="card">
<h3>Track sizing</h3>
<p>How the grid algorithm resolves a minmax floor before distributing free space.</p>
<a href="#">Read</a>
</article>
<article class="card">
<h3>Gap accounting</h3>
<p>Gaps are subtracted from the container before the track count is computed.</p>
<a href="#">Read</a>
</article>
<article class="card">
<h3>Collapsed tracks</h3>
<p>An empty auto-fit track resolves to zero width, and its two gaps collapse with it.</p>
<a href="#">Read</a>
</article>
<article class="card">
<h3>Intrinsic floors</h3>
<p>A percentage or min() floor is resolved against the grid container's inline size.</p>
<a href="#">Read</a>
</article>
<article class="card">
<h3>Ordering</h3>
<p>Auto-placement fills rows in document order, so reading order stays intact.</p>
<a href="#">Read</a>
</article>
</div>
</div>
</body>
</html>
Key technique: the min() guard
Write minmax(20rem, 1fr) and you have declared that every track is at least 20rem — a hard floor the algorithm has no permission to violate. Put that grid in a 17rem container and it does not fall back to one small column; it produces one 20rem column that sticks 3rem out of its parent, and you get a horizontal scrollbar on the whole page.
min(100%, 20rem) fixes it by making the floor conditional. The 100% resolves against the grid container's inline size, so in any container wider than 20rem the 20rem wins and behaviour is unchanged, while in a narrower container the percentage wins and the floor becomes exactly the space available. One column, no overflow, no media query.
Two details worth knowing. First, 100% here does not account for gap; with a single track there is no gap to subtract, so it is exact, and with more than one track the 20rem branch is already in effect. Second, this is a floor guard, not a general overflow cure — a track sized correctly can still be overflowed by its contents, which is a separate problem covered in Preventing Flex and Grid Overflow. The min-width: 0 on .card above is there for precisely that reason.
Variation: rows, and a dense masonry-ish gallery
The same machinery works on rows via grid-auto-rows, and pairs well with grid-auto-flow: dense for a gallery where some items span two tracks. Dense packing backfills holes left by wide items, at the cost of visual order diverging from DOM order.
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr));
grid-auto-rows: 10rem;
grid-auto-flow: dense; /* backfill gaps left by spanning items */
gap: 0.75rem;
}
/* Feature tiles take two tracks in each axis where there is room. */
.gallery__item--feature {
grid-column: span 2;
grid-row: span 2;
}
/* Never let a span exceed the available track count in a narrow container. */
@container (max-width: 30rem) {
.gallery__item--feature { grid-column: span 1; grid-row: span 1; }
}
Note the deliberate auto-fill here: in a gallery, a preserved trailing track keeps tile sizes constant across pages with different item counts, which reads as more consistent than tiles that resize when the last row is short. That consistency is the one clear argument for auto-fill over auto-fit. Because dense flow reorders items visually but not in the accessibility tree, keep it to decorative galleries where sequence carries no meaning — a keyboard user tabbing through will otherwise jump around the screen.
Browser support note
repeat() with auto-fit and auto-fill, and minmax(), are part of the original CSS Grid Level 1 implementation and shipped in every engine years ago — there is no fallback story to write in 2026. The min() function used in the guard arrived later but is also long settled and available everywhere current. If you still support engines that predate min(), minmax(20rem, 1fr) inside a @media (min-width: 22rem) block reproduces the guard's effect.
FAQ
What is the difference between auto-fit and auto-fill?
Both generate as many tracks as fit the container. auto-fill keeps the tracks it created even when no items occupy them, while auto-fit collapses those empty tracks to zero so the remaining items expand to fill the row.
Why does my auto-fit grid overflow on a narrow screen?
Because the minimum passed to minmax() is a fixed length larger than the container. A 20rem minimum cannot fit a 17rem container, so the track overflows. Write minmax(min(100%, 20rem), 1fr) so the minimum caps at the container width.
Does an auto-fit grid still need media queries? Usually not for the column count, which the track count already handles. You may still want a container query for changes the track algorithm cannot express, such as switching a card from a stacked to a side-by-side arrangement.
Should the flexible maximum in minmax() be 1fr or max-content?
Use 1fr for equal-width tracks that absorb leftover space evenly. max-content lets tracks grow to their content and produces unequal columns, which usually reads as accidental in a card grid.
Related
- CSS Grid and Subgrid Layouts — the parent guide to grid architecture.
- Subgrid vs an Independently Nested Grid — aligning the interiors of the cards this grid places.
- min-content, max-content, and fit-content() — the other intrinsic sizing functions available inside
minmax(). - Building Responsive Cards with Container Queries — restyling each card once the grid has sized it.
- Staggered List Animations with Custom Properties — cross-area: animating the cards in as the grid renders.
Related articles
More pages in the same section.