Writing · CSS
Every way to make a layout responsive
Media queries, container queries, fluid type, flexbox, grid and the units under them: each one played across a width you drag, and when to reach for which. 9 minutes, 6 demos.
tablet: 2 columns
1.cards {2 display: grid;3 grid-template-columns: 1fr;4}5@media (min-width: 640px) {6 .cards { grid-template-columns: repeat(2, 1fr); }7}8@media (min-width: 1024px) {9 .cards { grid-template-columns: repeat(3, 1fr); }10}
On this page
01 · The idea
One layout, every width
A page doesn’t get to choose the screen it lands on. The same markup has to read well on a phone held in one hand, a tablet on its side and a laptop with the window dragged half open. “Responsive” is just the name for that: a layout that changes with the room it’s given, instead of one drawn for a single width and squeezed to fit the rest.
CSS has more than one way to do it, and they answer different questions. Some ask how wide the screen is, some how wide the box is, and some don’t ask at all: they let the content push and give. Most good layouts use a few of them together, each where it fits. This piece plays them one at a time, then says when to reach for which.
02 · Media queries
Steps the screen decides
The oldest way, and still the most used. A media query wraps some CSS in a condition about the screen, usually its width, and the browser applies it only while the condition holds. The widths where the layout changes are called breakpoints.
.cards { display: grid; grid-template-columns: 1fr; }
@media (min-width: 640px) {
.cards { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.cards { grid-template-columns: repeat(3, 1fr); }
}That’s the demo at the top of the page: one column, then two at 640px, then three at 1024px. Write the smallest screen first and add to it as the screen grows (min-width), and each step only has to say what changes.
Media queries are right for the page’s frame: how many columns the page has, whether the navigation folds into a menu, how much margin there is round everything. They’re the wrong tool for a component that might sit in a wide column on one page and a narrow sidebar on the next: it only knows the screen, not where it was put.
03 · Container queries
The component reads its box
A container query asks the same kind of question about a box instead of the screen. Mark an element as a container, and anything inside it can change with the container’s width. Below, the same card sits in a sidebar and in the main column of one page, and the screen never changes width. Drag the line between them: each card stacks while its column is under 20rem and lays out side by side once it’s wider, so the two can disagree. A media query couldn’t do that: it would give both the same answer.
.sidebar
0px < 20rem
.main
0px < 20rem
sidebar: stacked · main: stacked
1.sidebar, .main {2 container-type: inline-size;3}4.card {5 display: flex;6 flex-direction: column;7}8@container (min-width: 20rem) {9 .card { flex-direction: row; }10}
Now switch container-type off. Both cards stop responding, because a container query only reads an element marked as a container; without that line there’s nothing to ask, and the query never matches. It’s the first thing to check when one doesn’t work.
This is what makes a component portable. The same card can go in a sidebar, a grid of three or a full-width hero, and it fits each without the page telling it anything. Inside a container, cqw is 1% of its width, the way vw is 1% of the screen’s.
04 · Fluid type
A size that follows the width
Steps are fine for columns, but type that jumps from one size to the next at a breakpoint can look clumsy: a heading that’s comfortable at 1023px is suddenly much bigger at 1024. clamp() lets a size grow smoothly with the screen instead, between a floor and a ceiling.
Notes
A heading that follows the width
Drag the window's edge: the heading grows with the screen, never under its floor or over its ceiling.
Read on900px wide → font-size: 44.8px
1/* fluid: following the width */2h1 {3 font-size: clamp(2rem, 1rem + 3.2vw, 3.5rem);4}5/* stepped: a size per breakpoint */6h1 { font-size: 2rem; }7@media (min-width: 640px) { h1 { font-size: 2.75rem; } }8@media (min-width: 1024px) { h1 { font-size: 3.5rem; } }
Read clamp(2rem, 1rem + 3.2vw, 3.5rem) as three parts, and only one of them sets the size at a time: never smaller than 2rem (the floor, up to a 500px screen), never bigger than 3.5rem (the ceiling, from 1250px), and in between, 1rem plus 3.2% of the screen’s width. The graph above the window draws exactly that, flat at each end, and the code marks the part in charge as you drag. The page in the window is laid out at the screen’s real width and shrunk to fit, so the heading takes the room it really would. Switch to the steps to see the same sizes reached with media queries, in jumps.
05 · Flexbox
A row that bends
Flexbox lays things out in a line and decides how they share it. It needs no breakpoints at all: give the items a starting size and let them wrap onto a new line when they run out of room.
1.row {2 display: flex;3 flex-wrap: wrap;4 gap: 0.5rem;5}6.chip {7 flex: 0 1 6rem; /* grow shrink basis */8}
Each item’s flex is three numbers. Grow: whether it takes a share of the space left over. Shrink: whether it gives some up when there isn’t enough. Basis: the size it starts from, 6rem here. With wrapping on, an item that can’t fit moves to the next line; with it off, they all shrink to stay on one, and the words get cut. Switch grow on and the items on each line stretch to fill it.
Flexbox suits one direction at a time: a row of buttons, tags, a toolbar, a card’s image beside its text.
06 · Grid
Tracks that fill the row
Grid works in two directions at once, rows and columns. One line of it makes a whole responsive gallery, again with no breakpoints: as many columns as fit, none narrower than a minimum, all sharing what’s left.
1 track, stretched to fill
1.gallery {2 display: grid;3 grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));4 gap: 8px;5}
.gallery {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}minmax(110px, 1fr) says a column is at least 110px and otherwise an equal share. auto-fit and auto-fill both make as many as fit, and differ only when there are fewer items than room: auto-fit lets the empty columns collapse so the items stretch across, auto-fill keeps them, drawn in ember in the demo, so the items stay their size. Use fit when a short row should fill the space, fill when items should look the same however many there are.
07 · The units
The pieces underneath
All of these lean on a handful of units and functions. Most of the time the right size is one the content gives, not one you type:
width: min-content
width: max-content
width: fit-content
1.a { width: min-content; } /* its longest word */2.b { width: max-content; } /* all on one line, overflowing */3.c { width: fit-content; } /* squeezed: as wide as the box */
min-contentis as narrow as the content can go: its longest word.max-contentis its width with no wrapping at all, even if that runs out of the box.fit-contentis max-content until the box is too small, then it gives way.vw/vhare 1% of the screen’s width and height. On phones,vhignores the browser’s toolbars, so a full-height section can hide under them;dvhis the height that’s really there.cqwis 1% of the nearest container’s width, the container query version ofvw.min()/max():width: min(100%, 40rem)is a measure that’s never wider than 40rem and never overflows its box, in one line.aspect-ratiokeeps a box’s shape as its width changes (aspect-ratio: 16 / 9for a video), with no padding hacks.
08 · Choosing
When to reach for which
- The page’s frame (columns, navigation, margins): media queries, smallest screen first, breakpoints where the content breaks.
- A component that goes in different places: container queries, so it fits its box and not the screen.
- Type and spacing that should grow with the screen:
clamp(), with a rem in the middle. - Things in a row (buttons, tags, toolbars): flexbox with wrapping, and no breakpoint at all.
- A grid of cards or images:
repeat(auto-fit, minmax(..., 1fr)), again no breakpoint. - Before any of these: let the content size itself (
min-content,fit-content,min()), and add a rule only where it breaks.