Components that scale like images
A small CSS pattern that turned out to be the missing piece in our design system: container queries plus an em cascade. Three lines of CSS, one mental shift.
I rebuilt this site's Instagram-carousel previews three times before I noticed I was solving the wrong problem.
The first version used media queries. Looked fine at desktop, terrible at mobile. So I added more breakpoints. Still wrong. The slide previews live in two completely different contexts. A 3-column grid on /posts, and a swipeable carousel on a post page where each card is much wider. Media queries can't fix that. They look at the viewport. The card doesn't care about the viewport. It cares about the box it landed in.
The second version threw ResizeObserver at it. The card measured itself, computed a font size, set it as inline style. It worked. It was also 30 lines of JavaScript to do what CSS should do natively.
The third version is the one we've been shipping. Three lines of CSS. Here it is.
The pattern
.card {
container-type: inline-size;
}
.card .inner {
font-size: 4cqi;
}
.card .title { font-size: 2.5em; }
.card .body { font-size: 0.9em; padding: 2em; }
.card .avatar { height: 1.4em; }That's the whole thing. Two CSS features that landed in evergreen browsers around 2022–2023, plus an old friend that suddenly became useful again.
Container queries let an element declare itself a container, and its descendants can size themselves against that element instead of the viewport. container-type: inline-size is the cheap version. It only tracks width, which is what most components need.
cqi is the unit that does the actual work. 1cqi equals 1% of the container's inline size. Like vw, but anchored to the card, not the screen.
The em cascade is the trick that ties it together. Set font-size once at the root in cqi. Everywhere else inside the card, use em for padding, gaps, typography, image heights. Every value is now a multiple of the card's own width.
Drop the card into a 250px sidebar: every measurement shrinks. Drop it into a 900px hero: every measurement grows. The composition stays identical. No breakpoints. No JS. No clamp(). Just CSS doing what it's been quietly trying to do for years.
The gotcha
The first time you write this, it breaks.
/* What I tried first. Looks reasonable. Doesn't work. */
.card {
container-type: inline-size;
font-size: 4cqi;
}The card renders enormous on a 1400px viewport. The font-size resolves to something around 50px. Why?
A container cannot query itself. The cqi unit skips past the element it's declared on and resolves against the next outer container. If no ancestor has container-type, it falls back to the viewport.
So 4cqi here doesn't mean 4% of the card. It means 4% of whatever container the card sits inside. If that's the viewport, you get the disaster above.
The fix is one extra element:
.card { container-type: inline-size; }
.inner { font-size: 4cqi; }Now the inner div is a descendant of the container, and cqi resolves correctly against the card's width. The em cascade flows from there.
I lost about an hour to this. Worth writing down.
What it's good for
The mental model is: components that should look identical at any size. Cards. Badges. Embed widgets. Slide previews. Avatar tiles. Thumbnail grids. Anywhere a component travels across multiple container widths and you want the proportions to stay consistent, the way an image stays consistent when you resize it.
What it's not good for: body copy. Long-form prose should follow the reader, not the box. A paragraph at 10px is unreadable even if it's "proportional to its container." For text people will actually read, use rem and media queries the old-fashioned way.
If you want both, readable text inside a scaling component, pair cqi with clamp():
.title {
font-size: clamp(14px, 4cqi, 28px);
}A floor and a ceiling. The card still scales between those bounds, but never below something legible.
Why this changed the way I build components
I used to think of components as having "states" for different sizes: mobile state, tablet state, desktop state. I'd write CSS that picked the right state for the current viewport. That's a response to a sizing problem, not a solution to it.
Container queries flip it. A component has one layout, and that layout naturally accommodates the space it's given. The viewport stops mattering. The breakpoint guesswork stops. The "this card looks weird in the sidebar" tickets stop.
It also makes design systems feel honest again. A <Card> exported from a library can actually behave the same in every consumer's app, regardless of where they drop it. No more "it works in our docs site but not in your dashboard."
Three lines of CSS. One mental shift. The slide previews on this very page use this exact pattern. Try resizing your browser window and watch the whole composition track together.
That's the entire trick.