Container Queries: A Component That Knows Its Container, Not the Viewport
Container Queries: A Component That Knows Its Container, Not the Viewport
You have a product card. An image above the title, a price, an "Add to cart" button. In the store's main grid, above a screen width of 768px, a media query switches it to a horizontal layout — image on the left, content on the right, because that makes better use of the available width. Works great. Then a product manager sees the same card and wants it in a narrow "recommended products" sidebar next to the main article content. You drop the component in unchanged — and it breaks. The horizontal layout, designed with 700+ pixels in mind, tries to squeeze into a 260-pixel column. The image gets crushed into a strip, the text wraps down to a single word per line.
You didn't make a CSS mistake. Your media query works exactly as it should — it's just answering the wrong question. @media (min-width: 768px) asks: "how wide is the viewport?" For a product card to look good, it should never care about the viewport's width — it should care about how much space its parent gave it. Those are two different questions that CSS treated as one for two decades, simply because it had no way to ask the second one.
Why a media query isn't enough
A media query is a global query. It doesn't matter whether your component sits in a 1200-pixel column or a narrow 260-pixel side card — @media (min-width: 768px) returns the same true in both cases, because it asks about the browser window, not about the element's parent. A component built solely around media queries is therefore correct only in the context it was originally designed for — and stops being correct the moment the same code lands anywhere else. This fundamentally breaks the promise components are supposed to make: that they can be safely reused anywhere in a layout.
Before 2023, the only real workaround was a ResizeObserver in JavaScript — you observe the element, measure its width on every change, push the result into state, and conditionally add a CSS class. It works, but it comes at a cost that rarely gets mentioned when adopting it: extra component state, a re-render on every resize, code you have to write by hand for every single component, and code that doesn't exist until JavaScript has actually run — so with server-side rendering, the first frame shows the layout "blind" regardless, before ResizeObserver has had a chance to measure anything.
jsx
// The pre-Container-Queries workaround – works, but costs youimport { useEffect, useRef, useState } from "react";const ProductCard = ({ product }) => {const cardRef = useRef(null);const [isWide, setIsWide] = useState(false);useEffect(() => {const el = cardRef.current;const observer = new ResizeObserver((entries) => {setIsWide(entries[0].contentRect.width > 320);});observer.observe(el);return () => observer.disconnect();}, []);return (<article ref={cardRef} className={`card ${isWide ? "card--wide" : ""}`}>{/* ... */}</article>);};
Container Queries solve the same problem without a single line of JavaScript, without state, and without a re-render — because the question "how much space do I have" goes right back to where CSS always asked it: the stylesheet.
How it actually works under the hood: containment, not just new syntax
The @container syntax alone looks like @media with a different name — but the real difference lies elsewhere, in what has to happen before the browser even agrees to answer that kind of query. For an element to become a "query container," you have to declare it explicitly:
css
.card-slot {container-type: inline-size;container-name: card;}@container card (min-width: 400px) {.card {grid-template-columns: 40% 1fr;}}
container-type: inline-size isn't just a switch that turns on query mode — it's a declaration of CSS Containment, a separate spec that tells the browser: "this element's content doesn't affect its size along the inline axis, so you can safely treat it as an independent island during layout." That's not a meaningless implementation detail — it's a necessary condition for container queries to exist at all without creating a loop. If the browser let an element simultaneously resize in response to its own width and affect that width through its content, you'd get a circular dependency: content resizes the container → the resize flips @container → the new rules change the content → the content resizes the container again. Containment cuts that loop off at the source, by forcing the container's size along a given axis to be determined independently of what's inside it.
That has a concrete, practical consequence for choosing a container-type value:
inline-size– containment only along the inline axis (usually horizontal). The element's height still flows freely from its content. This is the value you'll reach for 95% of the time — exactly like in the product card example.size– containment on both axes at once. The element completely stops relying on its content to determine its size — which means you must give it an explicit height (viaheightoraspect-ratio), or else containment will cut it off from its content's natural height and the height will effectively collapse to zero.normal– the default value, no containment, the element isn't a query container.
container-name is optional, but worth making a habit of — without it, @container (min-width: 400px) queries the nearest ancestor that happens to be a container, whatever that is. In a flat component that's harmless, but in a nested layout (a card inside a panel, the panel inside a column — where both the panel and the column are containers), a named container unambiguously states which specific ancestor you're asking about, instead of relying on accidental proximity in the DOM tree.
A practical example: one card, two contexts, zero JavaScript
Back to the product card from the intro — this time built so the same component renders correctly in both a wide grid and a narrow sidebar, with no knowledge of where it's been dropped.
jsx
const ProductCard = ({ product }) => (<div className="product-card-slot"><article className="product-card"><imgsrc={product.image}alt={product.name}className="product-card__image"/><div className="product-card__body"><h3 className="product-card__title">{product.name}</h3><p className="product-card__price">{product.price} $</p><button type="button" className="product-card__cta">Add to cart</button></div></article></div>);export default ProductCard;
css
/* The component's parent declares itself as a query container */.product-card-slot {container-type: inline-size;container-name: product-card;}/* Default layout: image above content – safe in narrow spaces */.product-card {display: grid;grid-template-columns: 1fr;gap: 12px;}.product-card__image {width: 100%;aspect-ratio: 4 / 3;object-fit: cover;border-radius: 8px;}/* Above 360px of CONTAINER width, not viewport – switch to a horizontal layout */@container product-card (min-width: 360px) {.product-card {grid-template-columns: 40% 1fr;align-items: center;}.product-card__image {aspect-ratio: 1 / 1;height: 100%;}}
The key decision here is hidden in the structure, not the CSS: container-type sits on .product-card-slot, the card's parent, not on .product-card itself. That's not an accident or an overcautious style choice — the spec explicitly rules out an element querying its own size via @container (the same circular-dependency problem again: an element can't simultaneously define a container and be styled in response to that container's size). That's why the typical, repeatable pattern is a thin wrapper-container on the outside and the actual component inside, styled within an @container block. The same CSS file, dropped once into the product grid (where the slot is 480px wide) and once into the sidebar (where the slot is 240px wide), produces two different, correct layouts — no prop, no modifier class, not a single line of JavaScript.
Container units: fluidity without the viewport
Container Queries brought along a second, lesser-known piece of the same spec: units relative to the container instead of the viewport — cqw (1% of the container's width), cqh (1% of its height), cqi and cqb (1% along the inline/block axis, so they also work correctly in vertically written languages), and cqmin/cqmax, analogous to vmin/vmax but calculated from the container's smaller or larger dimension.
Their natural use case is fluid typography inside a component, independent of how wide the user's screen happens to be:
css
.product-card__title {/* Scales with the card's width, not the screen's width */font-size: clamp(1rem, 0.85rem + 2cqi, 1.375rem);}
The difference from the already-familiar clamp()-with-vw trick is subtle but, in practice, fundamental: a title scaled with vw units resizes along with the entire page, so two copies of the same card — one in a full-width grid, one in a narrow sidebar — get an identical font size, because both see the same viewport. A title scaled with cqi responds to the actual space granted to that specific card — smaller in the sidebar, larger in the grid, even though the viewport is exactly the same width the whole time.
Pitfalls and best practices
container-type: sizewithout an explicit height eats the content. Containment along the block axis cuts the element off from its children's natural height — if you don't provideheightoraspect-ratio, the container effectively shrinks to zero height and its children visually vanish, even though they're still sitting right there in the DOM. In the vast majority of casesinline-sizeis enough and this problem never comes up.- A container can't query itself. If you're fighting with
@container"not working," the first suspect should be exactly this:container-typeand the selector being styled by@containersit on the same element. Separate them —container-typeon the wrapper, the target styles on the child, exactly as in the product card example above. - Container queries don't replace media queries — they complement them. A media query is still the right tool for page-level decisions (whether to show a sidebar at all, or switch navigation to a hamburger menu). A container query handles component-level decisions, wherever that component ends up. Good layout in 2026 typically uses both at once, each one exactly where the question it asks actually fits.
- Don't turn every div into a container "just in case." Containment has a real cost for the rendering engine — it's a signal to "treat me as an isolated layout unit," useful where you genuinely have a component reacting to its own size, not a default setting you slap onto the entire DOM tree.
- Browser support is no longer an issue.
container-typeand@containerreached Baseline (widely available) status in early 2023 — they work natively in Chrome, Safari, and Firefox, with no polyfills and no@supports. Thecqw/cqiunits and the rest of that family have exactly the same coverage.
Conclusion
A reusable component isn't one with enough props and modifier classes to manually handle every place it might end up — it's one that knows on its own how much space it has and reacts to that without anyone's help. For two decades, media queries were CSS's only tool for responsiveness, so naturally we overused them even in cases that were really about the parent, not the viewport. Container Queries aren't another syntactic variant of the same mechanism — they're a different question, asked at the right place in the DOM tree, backed by containment that guarantees the answer can never loop back on itself. The code in this article — one component, one CSS file, zero JavaScript — works correctly in the grid, in the sidebar, and in every other place you don't yet know you'll drop it into one day.