The short version
- repeat(auto-fit, minmax(300px, 1fr)) makes a responsive CSS grid without media queries: the browser fits as many columns as it can at the minimum width and the 1fr maximum stretches them to fill the row.
- Wrap the minimum width in min(..., 100%) so a single column can shrink to the container's width on screens narrower than the minimum, instead of overflowing.
- A minimum of max(300px, 100% / 4) gives three columns on wide screens even with gaps, and never more than three. With 100% / 3 the gaps leave room for only two.
- auto-fit collapses empty columns so the items stretch across the row, while auto-fill keeps the empty columns. They only differ when there are fewer items than columns.
- Make items span two columns only inside a media query. In a one-column grid, an item that spans two forces an extra column and the page scrolls sideways.
To make a responsive CSS grid without media queries, set grid-template-columns to repeat(auto-fit, minmax(<minimum width>, 1fr)). The browser fits as many columns as it can at that minimum width, stretches them to fill the row, and drops to fewer columns as the screen gets narrower.
Below I build a four-card feature section with that one line, then go through each piece of it: repeat(), auto-fit, minmax(), max() and min(). The only media query left makes two of the cards span two columns. The code is the React and Tailwind component from the video above, and there's a plain CSS version too.
The layout
The section has four cards. On a wide screen there are three columns: the first card spans two of them and the second card sits next to it, then the next row flips that, with the third card on its own and the fourth spanning two. As the screen gets narrower it drops to two columns, and on a phone every card stacks.
You've probably seen layouts like this one on landing pages. I build it with CSS grid and a few CSS functions, and the grid works out the number of columns by itself.
The one line that does the work
This is the CSS for the grid's columns:
grid-template-columns: repeat(auto-fit, minmax(min(max(300px, 100% / 4), 100%), 1fr)); It looks complicated, but it's four functions and a keyword nested inside each other. I'll go from the outside in.
repeat() and auto-fit
repeat(count, size) creates the same column size over and over. Normally the count is a number, like repeat(3, 1fr). Pass auto-fit instead and the grid creates as many columns as fit in the container, based on the size you give it. That's where the responsiveness comes from.
minmax() and 1fr
The size is minmax(minimum, maximum). Every column has to be at least the minimum wide, and can grow up to the maximum.
The maximum here is 1fr, one fraction of the free space. Once the grid has worked out how many columns fit, the columns share whatever space is left over equally, so the row is always filled edge to edge. If you put a fixed size there instead, the columns stop growing at that size and leave a gap at the end of the row.
Everything else in that line is the minimum column width.
max(300px, 100% / 4)
max() takes two values and returns whichever is larger. Here it's 300px or a quarter of the container's width.
On a big screen, a quarter of the container is more than 300px, so the minimum column width is 25%. On a smaller screen a quarter drops below 300px, so the minimum becomes 300px and you get fewer, wider columns.
I divide by 4 because the layout needs three columns: two for the first card and one for the card next to it. You'd think 100% / 3 would give three columns, but the grid also has a 2rem gap between columns, and three columns at a third of the width each plus two gaps is wider than the container. The browser then fits only two. I checked: with 100% / 3 the grid on a 1280px screen has two columns. With 100% / 4 there's room for three columns plus the gaps.
It also caps the grid at three columns, however wide the screen gets. Four columns of at least 25% each plus three gaps can never fit.
min(..., 100%)
The result of max() goes into min(), which returns the smaller of two values: the max() result or 100% of the container.
On a big screen the max() result is always smaller than 100%, because a quarter of the container or 300px is less than the whole container. So min() passes it through and nothing changes.
On a phone, max() returns 300px, and a phone's container can be narrower than that. A 320px screen with 20px of padding on each side leaves 280px. Without min(), the column would still be 300px wide and stick out of the container. With it, the minimum becomes 100% of the container, so the one column is exactly as wide as the screen allows.
I tested both on a 300px-wide screen. With min() the cards are 260px, the width of the container. Without it they're 300px, they stick out 20px past the edge of the screen and the page scrolls sideways.
min() and max() have worked in every major browser since 2020, so you can use them without a fallback.
auto-fit vs auto-fill
repeat() also takes auto-fill, and the two look the same until you have fewer items than columns. auto-fit collapses the empty columns to zero width, so the items stretch across the row. auto-fill keeps the empty columns at their size, so the items stay narrow and the rest of the row is blank.
Here's what happened when I put only two cards in this grid on a 1280px screen:
auto-fit | auto-fill | |
|---|---|---|
| Columns created | 3, the empty one at 0px | 3, all 392px |
| Width of each card | 604px | 392px |
| The rest of the row | Filled by the two cards | One empty column |
| Use it when | The items should always fill the row | The items should keep the same size |
For a section like this one, where the cards should fill the page, use auto-fit. With all four cards in the grid it makes no difference which one you pick.
The one media query
The grid handles the columns, but it can't know that the first and last cards should be wider. That part needs a media query: from 768px up (Tailwind's md breakpoint), the first and last cards span two columns.
@media (min-width: 768px) { .benefit:first-child, .benefit:last-child { grid-column: span 2; } } The media query matters. Below 768px the grid can have a single column, and a card that spans two columns in a one-column grid makes the browser add a second column that doesn't fit. I tried it on a 375px screen: the browser added a 40px column and the page scrolled sideways.
Here's where the layout changes with this exact code, a 2rem gap and 20px of padding on each side:
| Screen width | Columns | What you see |
|---|---|---|
| Under 672px | 1 | Every card stacked |
| 672px to 767px | 2 | Two rows of two cards |
| 768px to 1003px | 2 | First and last cards full width, the middle two side by side |
| 1004px and up | 3 | The full layout |
Only the 768px change comes from the media query. The grid works out the other two from the container's width.
The full component
This is the component from the video, in React with Tailwind v4. I tested it in a Vite app with React 19 and Tailwind 4.3.3:
export default function GridLayout() { const benefits = [ { img: "https://i.imgur.com/QZ1JVKk.png", title: "Track and optimize your sales pipeline", text: "Visualize, manage, and streamline your sales process for maximum efficiency and conversion rates.", }, { img: "https://i.imgur.com/CEX0jl7.png", title: "Stay Organized", text: "Assign, track tasks effortlessly to keep your team aligned and productive.", }, { img: "https://i.imgur.com/dl6YGru.png", title: "Contact Information", text: "Access detailed contact information at a glance.", }, { img: "https://i.imgur.com/JdliMW3.png", title: "Efficient Contact Management", text: "Centralize and organize all your contacts for easy access and effective communication.", }, ]; return ( <section className="py-20 bg-white"> <div className="max-w-7xl mx-auto px-5"> <div className="grid grid-cols-[repeat(auto-fit,minmax(min(max(300px,100%/4),100%),1fr))] gap-8"> {benefits.map((benefit, i) => ( <div key={i} className="p-5 rounded-xl shadow bg-gray-50 md:first:col-span-2 md:last:col-span-2 overflow-hidden"> <h4 className="text-3xl font-semibold mb-6">{benefit.title}</h4> <p className="text-gray-500">{benefit.text}</p> <div className="mt-8"> <img src={benefit.img} alt="" className="w-full rounded-lg border border-gray-300" /> </div> </div> ))} </div> </div> </section> ); } The section adds vertical padding and a white background. The div inside it is a container, capped at 1280px with max-w-7xl and centered with mx-auto. Its px-5 is the 20px of padding on each side that the widths in the table above account for.
The grid is the next div. Tailwind's arbitrary value syntax, the square brackets after grid-cols-, lets you put any value into grid-template-columns. Class names can't contain spaces, so the value is written without them. Tailwind builds it into the same CSS as above, and even simplifies 100%/4 to 25%. gap-8 is the 2rem gap.
Each card gets md:first:col-span-2 and md:last:col-span-2, which is the media query from the last section in Tailwind form. first: and last: are the :first-child and :last-child selectors, so only the first and last cards span two columns, and only from md up. overflow-hidden clips anything too wide for a card, like a long URL, so it doesn't spill over the card next to it.
The same grid in plain CSS
If you're not using Tailwind, here's the grid on its own:
<section class="benefits"> <div class="benefits-grid"> <div class="benefit">Track and optimize your sales pipeline</div> <div class="benefit">Stay organized</div> <div class="benefit">Contact information</div> <div class="benefit">Efficient contact management</div> </div> </section> .benefits { max-width: 80rem; margin: 0 auto; padding: 5rem 1.25rem; } .benefits-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(max(300px, 100% / 4), 100%), 1fr)); gap: 2rem; } .benefit { padding: 1.25rem; border-radius: 0.75rem; background: #f9fafb; overflow: hidden; } @media (min-width: 768px) { .benefit:first-child, .benefit:last-child { grid-column: span 2; } } I measured both versions at ten screen widths from 300px to 1920px, and they switch columns at the same widths.
Run it
Create a Vite app with React and TypeScript, and add Tailwind with its Vite plugin:
bun create vite@latest grid-demo --template react-ts cd grid-demo bun install bun add tailwindcss @tailwindcss/vite Add the plugin to vite.config.ts:
import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], }); Replace everything in src/index.css with Tailwind's import:
@import "tailwindcss"; Save the component as src/GridLayout.tsx, delete src/App.css, and render the component from src/App.tsx:
import GridLayout from "./GridLayout"; export default function App() { return <GridLayout />; } Start it with bun run dev and open the URL it prints. On a desktop screen you'll see the three-column layout with the wide first and last cards. Open the developer tools, switch to the responsive view and drag the width down: at 1003px it drops to two columns with the first and last cards full width, at 767px the cards go two by two, and below 672px every card stacks.
Play with the numbers
The nested min() and max() are what make this work. max() keeps the columns from getting too narrow on a big screen, and min() keeps them from overflowing on a small one. Between the two, the grid fills whatever space it has.
It took me a while to see why each piece was there, and reading about it only gets you so far. Recreate this in your own code and change the values. Swap the 4 for a 3 and watch the third column disappear, or take min() out and shrink the window until the cards hang off the side. Once you've watched the columns change, the maths makes a lot more sense.
Originally published on hassandev.me.


