Quick take: A responsive CSS grid without media queries works by handing track sizing to the grid itself. The one-liner
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))fits as many columns as the container allows and reflows down to a single column on small screens. No breakpoints, no JavaScript, one declaration.
You get a responsive CSS grid without media queries by describing the tracks you want instead of the exact number of columns. Write grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)), add a gap, and the browser packs in as many columns as fit at 280px each, then reflows to fewer columns, and finally to one, as the container narrows. The breakpoints happen automatically because the sizing is intrinsic. I've shipped card grids this way that went from four columns on a wide dashboard to a single column on a 360px phone without a single @media block. That's the whole trick. Everything below is why it works and where it bites.
How does repeat(auto-fit, minmax(280px, 1fr)) work?
Let's take that value apart piece by piece. It reads like a spell until you see what each token does.
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
repeat() is a shorthand so you don't type the same track size over and over. Normally you'd pass a fixed count, like repeat(3, 1fr) for three equal columns. Here the count is a keyword instead. auto-fit tells the browser to compute the count itself based on how many tracks fit the container. Each generated track is sized by minmax(280px, 1fr), which means "never smaller than 280px, but grow to an equal share of leftover space." So the browser divides the available inline width by 280px plus the gap, floors that to a whole number, and lays out exactly that many columns. Widen the container and one more column pops in. Shrink it and a column drops off. The reflow is continuous and free.
auto-fit vs auto-fill: what's the real difference?
This is the part people get wrong, and I did too for an embarrassingly long time. Both keywords create as many tracks as fit. The difference only appears when the row has extra space that no item fills.
/* Items stretch to fill the row */
.grid-fit { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
/* Empty phantom tracks stay, items keep their min size */
.grid-fill { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
Say your container is 1200px wide and you have two cards. With auto-fill, the grid still reserves the extra column tracks it could fit, leaves them empty at 280px each, and your two cards sit at the left looking lonely. With auto-fit, those empty tracks collapse to zero width, and the 1fr maximum lets your two real cards stretch to share the full 1200px. Per MDN, that collapse is the entire distinction. Which one do you want? Almost always auto-fit, because stretched cards look intentional and empty phantom columns look like a bug. Use auto-fill when you specifically want items to keep a fixed size and not balloon, like a photo grid where thumbnails shouldn't grow past their natural width.
What does minmax() actually control?
minmax(min, max) defines a size range for a track, and it's the load-bearing part of this pattern. The minimum sets the point where a column is allowed to drop to the next row. The maximum, usually 1fr, lets the surviving columns divide the space evenly.
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
gap: 1rem;
}
Notice min(320px, 100%) as the minimum. That's a small guard I add on almost every grid now. A bare minmax(320px, 1fr) can overflow when the container is narrower than 320px, because the track refuses to shrink past its floor. Wrapping the floor in min(320px, 100%) says "320px, unless that's wider than the container, in which case just use 100%." One item on a 300px screen then fits cleanly instead of forcing a horizontal scrollbar. It's four extra characters that kill a whole class of mobile overflow bugs.
Where does clamp() fit in?
clamp(min, preferred, max) gives you a fluid value that scales with the viewport but stays inside bounds. It pairs beautifully with intrinsic grids for the gap, the track floor, or padding, so those scale too instead of jumping.
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(clamp(240px, 30vw, 340px), 1fr));
gap: clamp(1rem, 2.5vw, 2rem);
}
Here the track minimum grows from 240px on a phone toward 340px on a big monitor, tracking 30vw in between. The gap breathes the same way, from 1rem up to 2rem. No breakpoints touch any of it. Is this overkill for a simple three-card row? Probably. But on content-heavy dashboards, fluid gaps and floors remove that stair-step feeling you get from hard breakpoints, and users never notice the layout "snapping."
How do gap and the grid work together?
gap sets the space between tracks, and the auto-fit math already accounts for it, which surprises people. The browser fits columns using track width plus gap, so you don't subtract anything by hand.
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px 16px; /* row-gap 24px, column-gap 16px */
}
You can pass one value for both axes or two for row and column separately. The old margin hacks, negative margins on the container to cancel edge gaps, are gone. gap only applies between items, never on the outer edges, so your grid sits flush with its container. If you've ever fought :last-child margin rules to kill a trailing gutter, you'll appreciate how little there is to think about here.
Where does this pattern break?
It's not magic, and pretending it is will burn you. Three failure modes come up often.
First, very narrow containers. If your minmax floor is 280px and the grid lives inside a 240px sidebar, the track can't shrink and content overflows. The min(280px, 100%) trick fixes it, but you have to remember to apply it. Second, single-item rows. With one card and auto-fit, that card stretches to the full width, which can look absurd at 1400px. Cap it with a max-width on the item or switch to auto-fill so it keeps a sane size. Third, and this is the real ceiling: auto-fit only changes how many columns exist. It can't restyle the inside of a card. When a card needs to go from a stacked layout to an image-beside-text layout based on its own width, no amount of grid track math helps. That's exactly when a container query beats it, because the query reacts to the item's available space, not the column count. For the broader tradeoff between one-dimensional and two-dimensional layout, see the pillar on CSS Grid vs Flexbox.
Related
- CSS Container Queries - reach for these when the viewport-based, track-count approach isn't enough and a component needs to restyle based on its own width.