“Smart” Layouts With Container Queries
Modern CSS keeps giving us a lot of new, easier ways to solve old problems, but often the new features we’re getting don’t only solve old problems, they open up new possibilities as well.
Container queries are one of those things that open up new possibilities, but because they look a lot like the old way of doing things with media queries, our first instinct is to use them in the same way, or at least a very similar way.
When we do that, though, we aren’t taking advantage of how “smart” container queries are when compared to media queries!
Because of how important media queries were for ushering in the era of responsive web design I don’t want to say anything mean about them… but media queries are dumb. Not dumb in terms of the concept, but dumb in that they don’t know very much. In fact, most people assume that they know more than they do.
Let’s use this simple example to illustrate what I mean:
html {
font-size: 32px;
}
body {
background: lightsalmon;
}
@media (min-width: 35rem) {
body {
background: lightseagreen;
}
}
What would the viewport size be for the background color to change? If you said 1120px
wide — which is the product of multiplying 35 by 32 for those who didn’t bother doing the math — you aren’t alone in that guess, but you’d also be wrong.
Remember when I said that media queries don’t know very much? There are only two things they do know:
- the size of the viewport, and
- the browser’s font size.
And when I say the browser’s font size, I don’t mean the root font size in your document, which is why 1120px
in the above example was wrong.
The font size they look at is the initial font size coming from the browser before any values, including the user agent styles, are applied. By default, that’s 16px
, though users can change that in their browser settings.
And yes, this is on purpose. The media query specification says:
Relative length units in media queries are based on the initial value, which means that units are never based on results of declarations.
This might seem like a strange decision, but if it didn’t work that way, what would happen if we did this:
html {
font-size: 16px;
}
@media (min-width: 30rem) {
html {
font-size: 32px;
}
}
If the media query looked at the root font-size
(like most assume it does), you’d run into a loop when the viewport would get to 480px
wide, where the font-size
would go up in size, then back down over and over again.
Container queries are a lot smarter
While media queries have this limitation, and for good reason, container queries don’t have to worry about this type of problem and that opens up a lot of interesting possibilities!
For example, let’s say we have a grid that should be stacked at smaller sizes, but three columns at larger sizes. With media queries, we sort of have to magic number our way to the exact point where this should happen. Using a container query, we can determine the minimum size we want a column to be, and it’ll always work because we’re looking at the container size.
That means we don’t need a magic number for the breakpoint. If I want three columns with a minimum size of 300px
, I know I can have three columns when the container is 900px
wide. If I did that with a media query, it wouldn’t work, because when my viewport is 900px
wide, my container is, more often than not, smaller than that.
But even better, we can use any unit we want as well, because container queries, unlike media queries, can look at the font size of the container itself.
To me, ch
is perfect for this sort of thing. Using ch
I can say “when I have enough room for each column to be a minimum of 30 characters wide, I want three columns.”
We can do the math ourselves here like this:
.grid-parent { container-type: inline-size; }
.grid {
display: grid;
gap: 1rem;
@container (width > 90ch) {
grid-template-columns: repeat(3, 1fr);
}
}
And this does work pretty well, as you can see in this example.
As another bonus, thanks to Miriam Suzanne, I recently learned that you can include calc()
inside media and container queries, so instead of doing the math yourself, you can include it like this: @container (width > calc(30ch * 3))
as you can see in this example:
A more practical use case
One of the annoying things about using container queries is having to have a defined container. A container cannot query itself, so we need an extra wrapper above the element we want to select with a container query. You can see in the examples above that I needed a container on the outside of my grid for this to work.
Even more annoying is when you want grid or flex children to change their layout depending on how much space they have, only to realize that this doesn’t really work if the parent is the container. Instead of having that grid or flex container be the defined container, we end up having to wrap each grid or flex item in a container like this:
.card-container { container-type: inline-size; }
It’s not that bad in the grand scheme of things, but it is kind of annoying.
Except there are ways around this!For example, if you’re using repeat(auto-fit, ...)
you can use the main grid as the container!
.grid-auto-fit {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(30ch, 100%)), 1fr);
container-type: inline-size;
}
Knowing that the minimum size of a column is 30ch
, we can leverage that info to restyle individual grid items depending on how many columns we have:
/* 2 columns + gap */
@container (width > calc(30ch * 2 + 1rem)) { ... }
/* 3 columns + gaps */
@container (width > calc(30ch * 3 + 2rem)) { ... }
I’ve used this in this example to change the styles of the first child in my grid based on whether we have one, two, or three columns.
And while changing the background color of something is great for demos, we can, of course, do much more with this:
The downside to this approachThe only downside I’ve found using this approach is that we can’t use custom properties for the breakpoints, which would really improve the DX of this.
That should eventually change considering custom media queries are in the spec editor’s draft of the Media Queries Level 5 specifications, but its been in there for a while with no movement from any browsers, so it might be a long time before we can use them.
And while my opinion is that having custom properties for these would both make them more readable and easier to update, it opens up enough possibilities that it’s still worth it without them.
What about flexbox?With flexbox, the flex items are what define the layout, so it’s a little strange in that the sizes we apply on the items are what are important in the breakpoints.
It can still work, but there is a big issue that can arise if you do this with flexbox. Before we look at the issue, here is a quick example of how we can get this working with flexbox:
.flex-container {
display: flex;
gap: 1rem;
flex-wrap: wrap;
container-type: inline-size;
}
.flex-container > * {
/* full-width at small sizes */
flex-basis: 100%;
flex-grow: 1;
/* when there is room for 3 columns including gap */
@container (width > calc(200px * 3 + 2rem)) {
flex-basis: calc(200px);
}
}
In this case, I used px
to show it works as well, but you could use any unit there, as I did with the grid examples.
This might look like something you can use a media query for as well — you can use the calc()
in them too! — but this would only work in one if the parent has a width that matches the viewport width, which most of the time isn’t the case.
This breaks if the flex items have paddingA lot of people don’t realize it, but the flexbox algorithm doesn’t take padding or borders into account, even if you change your box-sizing
. If you have padding
on your flex items, you’ll basically have to magic number your way to getting it to work.
Here’s an example where I added some padding but I haven’t changed anything else, and you’ll notice one of those awkward two-columns with one stretched on the bottom layouts at one point:
Because of this, I do generally find myself using this type of approach more often with grid than flexbox, but there are definitely situations where it can still work.
Like before, because we’re aware of how many columns we have, we can leverage that to make more dynamic and interesting layouts depending on the space available for a given element, or elements.
Opening up some interesting possibilitiesI’ve only started playing around with this type of thing, and I’ve found that it’s opened up some new possibilities that we never had with media queries, and that makes me excited to see what else is possible!