An ongoing series on techniques that ship with the browser already - no library, no build step required.
For twenty years, responsive design has meant one thing: media queries. You ask how wide the screen is, and you rearrange the page to suit. It works, and it built the modern web. But it answers a question we often don’t have.
Take a card with a thumbnail and some text. In a wide main column you want them side by side. Dropped into a narrow sidebar, you want them stacked. The thing that should decide the layout is how much room the card has - not how big the screen is. A media query can’t tell the difference. The same card can sit in a roomy column and a cramped sidebar on the exact same screen, and the screen width is identical in both.
Container queries fix exactly this. Instead of asking about the viewport, a component asks about the space it’s actually been given, and lays itself out from that. Here’s one you can play with - grab the handle on the right and drag it in and out:
Drag the handle on the right edge to resize - or focus it and use the ← → keys.
The same card, both ways
Narrow, it stacks. Give it room and it lays out side by side - and it never once looked at your screen size to decide.
HTML
<div class="cq-demo">
<p class="cq-hint">Drag the handle on the right edge to resize - or focus it and use the ← → keys.</p>
<div class="cq-stage">
<div class="cq-resize">
<article class="cq-card">
<div class="cq-thumb" aria-hidden="true"></div>
<div class="cq-body">
<span class="cq-eyebrow">Field note</span>
<h4>The same card, both ways</h4>
<p>Narrow, it stacks. Give it room and it lays out side by side - and it never once looked at your screen size to decide.</p>
</div>
</article>
</div>
<div
class="cq-handle"
role="separator"
tabindex="0"
aria-orientation="vertical"
aria-label="Resize the container"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="50"
title="Drag to resize"
>
<span class="cq-grip" aria-hidden="true"></span>
</div>
</div>
</div>CSS
.cq-hint {
font-size: 0.8rem;
color: var(--color-gray-500);
margin: 0 0 0.5rem;
}
.cq-stage {
display: flex;
align-items: stretch;
max-width: 100%;
overflow: hidden;
}
.cq-resize {
container-type: inline-size;
flex: none;
width: 420px;
max-width: calc(100% - 18px);
padding: 0.75rem;
border: 1px dashed var(--color-gray-300);
background: var(--color-gray-50);
overflow: hidden;
}
/* Full-height grip on the right edge. It sits beside the container in the
flex row, so align-items: stretch makes it exactly as tall as the box. */
.cq-handle {
flex: none;
width: 18px;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
background: var(--color-gray-100);
border: 1px solid var(--color-gray-300);
border-left: none;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.cq-handle:hover {
background: var(--color-gray-200);
}
.cq-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.cq-grip {
width: 6px;
height: 34px;
border-radius: 3px;
background: var(--accent);
}
.cq-card {
display: flex;
flex-direction: column;
gap: 1rem;
background: var(--color-white);
border: 1px solid var(--color-gray-200);
padding: 1.25rem;
}
.cq-thumb {
flex: none;
height: 90px;
background: var(--accent);
opacity: 0.9;
}
.cq-eyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.7rem;
font-weight: 700;
color: var(--accent);
}
.cq-card h4 {
margin: 0.3rem 0;
color: var(--color-gray-900);
}
.cq-card p {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-gray-500);
}
@container (min-width: 360px) {
.cq-card {
flex-direction: row;
align-items: center;
}
.cq-thumb {
width: 120px;
height: 120px;
}
}JS
// The container-query reflow above is pure CSS. This only powers the drag
// handle for the demo - it sets the container's width; CSS does the rest.
(function () {
document.querySelectorAll(".cq-demo").forEach(function (demo) {
var stage = demo.querySelector(".cq-stage");
var box = demo.querySelector(".cq-resize");
var handle = demo.querySelector(".cq-handle");
if (!stage || !box || !handle) return;
var MIN = 200;
function maxW() {
return Math.max(MIN, stage.clientWidth - handle.offsetWidth);
}
function apply(px) {
var hi = maxW();
var w = Math.max(MIN, Math.min(px, hi));
box.style.width = w + "px";
handle.setAttribute(
"aria-valuenow",
String(Math.round(((w - MIN) / (hi - MIN)) * 100)),
);
}
// Start a little above the 360px threshold, so it opens as a row.
requestAnimationFrame(function () {
apply(Math.min(420, maxW()));
});
var dragging = false,
startX = 0,
startW = 0;
handle.addEventListener("pointerdown", function (e) {
dragging = true;
startX = e.clientX;
startW = box.getBoundingClientRect().width;
handle.setPointerCapture(e.pointerId);
e.preventDefault();
});
handle.addEventListener("pointermove", function (e) {
if (dragging) apply(startW + (e.clientX - startX));
});
function stop(e) {
dragging = false;
try {
handle.releasePointerCapture(e.pointerId);
} catch (_) {}
}
handle.addEventListener("pointerup", stop);
handle.addEventListener("pointercancel", stop);
handle.addEventListener("keydown", function (e) {
var step = e.shiftKey ? 48 : 16;
var w = box.getBoundingClientRect().width;
if (e.key === "ArrowLeft") {
apply(w - step);
e.preventDefault();
} else if (e.key === "ArrowRight") {
apply(w + step);
e.preventDefault();
}
});
window.addEventListener("resize", function () {
apply(box.getBoundingClientRect().width);
});
});
})();Nothing in that card knows or cares how wide your browser is. It only knows how much room it’s sitting in, and it reflowed when that room crossed a threshold. Drop the same card into a sidebar and a full-width row on one page, and each copy would lay itself out correctly without a single extra line.
Two new lines
The whole thing rests on two pieces. First, you nominate an element as a container - something whose size its children are allowed to ask about:
.card-area {
container-type: inline-size;
}
inline-size means “let children query the width.” That’s almost always what you want, and it’s the common case.
Then, instead of @media, you write @container, and the query measures that nearest container rather than the screen:
.card {
display: flex;
flex-direction: column;
}
@container (min-width: 360px) {
.card {
flex-direction: row;
}
}
Read it the same way you’d read a media query, with one swap: “when the container is at least 360px wide,” not “when the screen is.” The component now carries its own responsive behavior, and that behavior travels with it wherever you put it.
Units that measure the container
There’s a companion to this worth knowing: container query units. cqi is one percent of the container’s inline size, so font-size: 5cqi means “five percent of however wide my container is.” It lets type and spacing scale to the component’s space rather than the page’s, which is handy for things like cards that need to feel right at wildly different sizes.
.card h4 {
font-size: clamp(1rem, 5cqi, 1.5rem);
}
That heading grows and shrinks with the card it lives in, bounded so it never gets silly in either direction. You don’t reach for this every day, but when you need it, nothing else does the job as cleanly.
Why this matters more than it sounds
If you build pages as one-off layouts, container queries are a nice convenience. If you build with components - and any site that’ll grow should - they change how robust your work is.
A component built with container queries is genuinely self-contained. It doesn’t carry hidden assumptions about the width of the page it expects to live on. You can put it in a three-column grid, a narrow aside, a wide hero, a modal, and it does the right thing in each, because it was only ever responding to its own space. That’s the same instinct behind the other techniques we keep coming back to - dark mode with one set of variables, an accordion that’s just HTML. Build the component so the platform does the adapting, and you stop maintaining a pile of special cases.
The old way, you’d wire a card’s layout to page-level breakpoints and quietly hope nobody ever moved it. The first time someone did, it broke, and you’d patch in another media query for the new spot. Container queries retire that whole genre of bug. The component minds its own width, and you get to stop thinking about it.
Support is no longer the catch it was a couple of years ago - every current browser handles this, and has for a while:
Browser support · container-type / @container
Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
If you’re building anything component-shaped today, it’s a tool worth reaching for by default. And if your current site is the kind held together by a tangle of breakpoint patches, that’s often a sign the foundation is worth a closer look - we’re happy to take one.



