Container queries vs media queries is not an either/or choice. Media queries answer “how big is the viewport?”, so they belong to page layout: the grid, the navigation, whether the sidebar exists at all. Container queries answer “how much room does this component have?”, so they belong to the component. A card, a product tile or a form row should style itself from the width of the box it sits in, and it should not care whether that box is a main column or a 280px sidebar.
Size container queries have been Baseline widely available since early 2023 (Chrome 105, Safari 16, Firefox 110), so there is no support argument left for keeping component breakpoints in @media. The remaining questions are practical: where to put container-type, which value to use, and which of the newer query types are safe to ship.
When does a media query still win?
Anything that depends on the device rather than on layout. prefers-reduced-motion, prefers-color-scheme, hover, pointer and print are all media features with no container equivalent, and they never will have one.
Page-level layout is the other case. The top-level grid has exactly one container, the viewport, and a media query says that directly. Wrapping <body> in a container to query it adds a level of indirection with nothing to gain.
The trap is the middle ground. A .card that stacks below 600px viewport width works on the landing page, then breaks the day it is dropped into a two-column dashboard where its column is 480px wide on a 1440px screen. Every one of those bugs is a component breakpoint written as a viewport breakpoint. That is the case container queries exist for.
How do I convert a component?
Two rules. The container is the parent, and the query goes on the children.
/* The slot the card lives in. Not the card. */
.card-slot {
container: card / inline-size;
}
.card {
display: grid;
gap: 1rem;
}
.card h2 {
font-size: 1.125rem;
}
@container card (width >= 30rem) {
.card {
grid-template-columns: 12rem 1fr;
}
.card h2 {
font-size: clamp(1.25rem, 1rem + 2cqi, 2rem);
}
}
container: card / inline-size is the shorthand for container-name: card and container-type: inline-size. The name is optional, but naming the container means a nested card queries the slot it was meant to query, rather than whichever ancestor happens to be nearest. Names are cheap, so use them.
The reason the container has to be the parent is in the spec: “style rules applying to a query container’s flat tree descendants can be conditioned by querying against it”. An element cannot query itself. If you put container-type on .card and then write @container (width >= 30rem) { .card { ... } }, the rule matches nothing and there is no error to tell you why.
2cqi is 2% of the container’s inline size. The six container units (cqw, cqh, cqi, cqb, cqmin, cqmax) shipped with the same browser versions as @container itself, and they are the right unit for fluid type inside a component, where vw would scale with the wrong thing.
Which container-type should I use?
inline-size, almost always.
The difference matters because of containment. Per the spec, size “applies style containment and size containment to the principal box”, which means the element’s height can no longer come from its children. An empty div with container-type: size and no explicit height collapses to zero. inline-size only applies inline-size containment, and in a normal block layout the width already comes from the parent, so nothing changes visually. Reach for size only when you need to query height, and only on an element that gets its height from somewhere else (a fixed-height panel, a grid track, the viewport).
One thing that changed under people’s feet: originally both values also applied layout containment, which made the container a new containing block for position: fixed descendants and a new stacking context. Browsers removed the layout containment in Chrome 129, Safari 18.4 and Firefox 133, according to the MDN compatibility data. If a modal inside a card started rendering inside the card back in 2023, the fix has already shipped.
container-type can also be combined: container-type: inline-size scroll-state makes one element serve both a size and a scroll-state query.
Are style queries safe to ship?
For custom properties, yes, as of September 2026. @container style(--variant: compact) works in Chrome 111 and Safari 18, and Firefox caught up in Firefox 151 (May 2026). Firefox users on ESR builds older than that will not match the query, so treat a style query the same way you treat any progressive enhancement: the unmatched state has to look acceptable.
No container-type is needed. Every element is a style query container by default, which makes this a clean way to pass a mode down from a parent without a class on every child:
.sidebar {
--density: compact;
}
@container style(--density: compact) {
.card {
padding: 0.5rem;
gap: 0.5rem;
}
.card p:not(:first-of-type) {
display: none;
}
}
Two limits. Only custom properties can be queried; style(display: flex) or any other regular property is in the spec but in no browser yet. And the range form, style(--columns > 2), is Chrome 142 and Firefox 151 only, so pair it with @property to give the custom property a numeric type and keep a non-range fallback for Safari.
Style queries also do not replace :has() or data attributes. They react to the computed value on the container, which means a --density set by a media query on body inherits down and flips every card at once. That is the feature.
What about scroll-state queries?
Chrome and Edge only, as of September 2026. @container scroll-state(stuck: top) shipped in Chrome 133 (February 2025), with scrollable and snapped alongside it and scrolled added in Chrome 144. Neither Safari nor Firefox has shipped any of it, and the web-features data still lists it with no Baseline status.
That said, the classic use is harmless as an enhancement:
.site-header {
container-type: scroll-state;
position: sticky;
top: 0;
}
@container scroll-state(stuck: top) {
.site-header nav {
box-shadow: 0 2px 8px rgb(0 0 0 / 0.15);
}
}
Safari and Firefox users get a sticky header without the shadow. Nobody is worse off, and you have deleted an IntersectionObserver and a sentinel element. Use it for that and for scroll-snap carousels; do not use it for anything a user needs to see, such as a “more content below” affordance, until the other two engines ship.
scroll-state applies no containment at all, so the collapse problem from size does not apply here.
How do I structure a real stylesheet?
Put page layout in media queries at the top, and put every component’s breakpoints in @container next to that component. Name each container after the slot, not the component (card was a shortcut above; product-grid-cell is more honest). Keep the fallback state the narrow one, since an unsupported query is simply ignored and the narrow layout is the one that fits everywhere.
This also settles the Tailwind question. Tailwind v4 ships @container and @sm: style variants that compile to the same at-rule, so the decision is the same: viewport variants for layout, container variants inside components.
If you are still on a design that hands every component a list of viewport breakpoints, the migration is mechanical. Wrap each slot, rename @media (min-width: 600px) to @container slot (width >= 37.5rem), and delete the per-page overrides that were only there because the component could not see its own width. Our responsive web design work has gone this way for a couple of years; it is the same technique that lets anchor positioning replace Floating UI and scroll-driven animations replace scroll listeners: the browser already knows the layout, so stop measuring it in JavaScript.
I would not convert the top-level grid, and I would not ship a scroll-state query that a user needs in order to find content. Everything else, convert.
Sources: MDN container queries guide, MDN @container, MDN size and style queries, CSS Conditional Rules Level 5, Chrome scroll-state queries.