CSS Container Queries
31. July, 2024 • 8 min read • Develop
Asking the box, not the window
A card component does not care how wide your monitor is. It cares how much room it has been given. Media queries have never been able to express that, and every workaround I have written for it involved JavaScript measuring a div and putting a class on it. Container queries finally make it a stylesheet problem.
I ended my post on modern CSS techniques by complaining about exactly this gap, so it seems fair to spend a post on how it closes. What follows is the syntax, the units, the parts that behave unexpectedly, and where browser support actually stands.
The problem
Put a product card in a three-column grid on a desktop and it wants a horizontal layout: image left, text right. Put the same card in a narrow sidebar and it wants to stack. With media queries, the card cannot know which situation it is in. All it knows is the width of the window, which tells you nothing about the width of the column the card landed in.
So you end up with variant classes. .card--wide, .card--compact, .card--sidebar, each one a promise about where the component will be used, and each one broken the first time somebody uses it somewhere new. Container queries remove the need for the promise.
The syntax
Two steps. Declare an element as a query container, then write rules that respond to its size.
.container {
container-type: inline-size;
}
@container (min-width: 300px) {
.item {
background-color: red;
}
}Declaring a container
container-type takes three values:
inline-sizequeries the inline dimension only, which in a horizontal writing mode means the width. This is what you want roughly nine times out of ten.sizequeries both dimensions.normalis the default and makes the element a container for style queries only, not size queries.
There is also container-name, and a container shorthand that sets both:
.card-wrapper {
container: card / inline-size;
}Querying it
Inside an @container block, the rules apply to descendants of the nearest ancestor container:
@container (min-width: 300px) {
.item {
background-color: red;
}
}
@container (min-width: 500px) {
.item {
background-color: blue;
}
}If you gave the container a name, address it by name and the query stops depending on which container happens to be closest:
@container card (min-width: 500px) {
.item {
display: grid;
grid-template-columns: 120px 1fr;
}
}I have come round to naming containers by default. Unnamed queries work fine until somebody wraps your component in another container, at which point the styles start resolving against a box you have never heard of.
Both dimensions
If you set container-type: size, you can query height as well:
.container {
container-type: size;
height: 400px;
}
@container (min-width: 300px) {
.item {
background-color: lightblue;
}
}
@container (min-height: 200px) {
.item {
border: 2px solid lightcoral;
}
}Note the explicit height on the container. It is not decoration, and I explain why in the gotchas below.
Nesting
Containers nest, and each query resolves against the nearest matching ancestor:
.outer {
container: layout / inline-size;
}
.inner {
container: panel / inline-size;
}
@container panel (min-width: 300px) {
.item {
background-color: lightgreen;
}
}
@container layout (min-width: 900px) {
.item {
background-color: lightcoral;
}
}With names, that reads unambiguously. Without them, both blocks would resolve against .inner for any .item inside it, and the second rule would never fire the way you expected.
Container queries against media queries
| Media queries | Container queries | |
|---|---|---|
| Responds to | Viewport and device features | Size of a specific ancestor element |
| Scope | The whole document | Descendants of that container |
| Good for | Page-level layout shifts, print styles, prefers-reduced-motion |
Component-level layout, design systems, anything reused in more than one slot |
| Component portability | The component must know where it lives | The component adapts wherever you put it |
They are not competitors. I still use media queries for the page skeleton and for things that genuinely are properties of the device. Everything inside a component is a container query now.
The units
Container queries bring their own length units, all relative to the query container:
cqwis 1% of the container’s widthcqhis 1% of the container’s heightcqiis 1% of the container’s inline sizecqbis 1% of the container’s block sizecqminis the smaller ofcqiandcqbcqmaxis the larger of the two
Prefer cqi and cqb over cqw and cqh if you care about writing modes; they are the logical versions of the same idea.
Where these earn their place is in the declarations, not in the condition. Type that scales with the component is the obvious use:
.card {
container: card / inline-size;
}
@container card (min-width: 400px) {
.card__title {
font-size: max(1.5rem, 1.2rem + 1.5cqi);
}
.card__body {
padding: 4cqi;
}
}Using container units inside the @container condition itself is legal but confusing, because they resolve against the parent container rather than the one you are querying. I would avoid it. px and rem in the condition, cqi in the rules, is a boundary that has never surprised me.
Gotchas
Four things bit me while I was working this out.
An element cannot query itself. The rules inside an @container block apply to descendants of the container, never to the container element. If you want a card to restyle itself, you need a wrapper element to be the container, which is a slightly annoying extra div.
container-type: size collapses your element. Size containment in the block direction means the element’s height no longer depends on its content. Without an explicit height it computes to zero and everything inside vanishes. This looks catastrophic and takes ten minutes to work out the first time. Use inline-size unless you genuinely need to query height.
inline-size containment cuts the other way too. The container’s own width can no longer be determined by its contents, so a shrink-to-fit element such as a floated box or an inline-block will not size itself as it did before. In a normal block or grid layout you will never notice.
Query containers cost something. Every element with a container-type is an element the engine has to watch for size changes. Declaring one on a wrapper is nothing; declaring one on every list item in a table of two thousand rows is a decision you will feel.
Browser support
Size container queries have been in stable browsers for a while now:
- Chrome and Edge since version 105, from August 2022
- Safari since 16.0, from September 2022
- Firefox since 110, in February 2023
That puts them in every evergreen browser, and has done for well over a year. In practice you can use them without a polyfill, provided your traffic isn’t heavy on Safari 15 or some old Chromium-based embedded browser you inherited.
Style queries, which test a custom property’s value rather than a size, are the newer half of the specification. Chrome and Firefox have shipped them for custom properties; Safari had not at the time of writing. I’d treat them as a progressive enhancement for now.
If you do need a fallback, @supports handles it cleanly:
@supports (container-type: inline-size) {
.container {
container-type: inline-size;
}
@container (min-width: 300px) {
.item {
background-color: lightblue;
}
}
}
@supports not (container-type: inline-size) {
.item {
background-color: lightgray;
}
}Write the unsupported case as your baseline and layer the container query on top, rather than the other way round. Old browsers get a single-column layout that works, new ones get the adaptive version.
Where I’ve got to
I have started removing size modifier classes from components as I touch them and replacing them with a named container plus two or three breakpoints. It is less code and the components stop lying about where they can be used.
The wrapper-element requirement still bothers me. Every component that wants to respond to its own width needs an extra div whose only job is to be a container, and in a deeply nested React tree that adds up. There’s talk of allowing an element to query itself in a future revision of the specification. I’ll believe it when I can ship it 🙂
‘Till next time!