Recently, Chris Coyier wrote a blog post posing the question:
Now that container queries are supported in all browser engines, why aren't more developers using them?
Chris's post lists a number of potential reasons (for example, lack of awareness, old habits die hard), but there's one particular reason that stands out.
Some developers say they want to use container queries now but think they can't because they still have to support older browsers.
As you may have guessed from the title, we think it's possible for most developers to use container queries now—in production—even if you have to support older browsers. This post walks you through the approach we recommend to do that.
A pragmatic approach
If you want to use container queries in your code now, but you want the experience to look the same in all browsers, you can implement a JavaScript-based fallback for browsers that don't support container queries.
The question then becomes: how comprehensive should the fallback be?
As with any fallback, the challenge is to strike a good balance between usefulness and performance. For CSS features, it's often impossible to support the full API (see why not use a polyfill). However, you can get pretty far by identifying the core set of functionality that most developers want to use, and then optimize the fallback for just those features.
But what is the "core set of functionality" that most developers want for container queries? To answer that question, consider how most developers build responsive sites currently with media queries.
Pretty much all modern design systems and component libraries have standardized on mobile-first principles, implemented using a set of predefined breakpoints (such as SM
, MD
, LG
, XL
). Components are optimized to display well on small screens by default, and then styles are conditionally layered on to support a fixed set of larger screen widths. (See the Bootstrap and Tailwind documentation for examples of this.)
This approach is just as relevant to container-based design systems as it is to viewport-based design systems because, in most cases, what's relevant to designers is not how big the screen or viewport is, it's how much space is available to the component in the context that it's been placed. In other words, rather than breakpoints being relative to the entire viewport (and applicable to the entire page), breakpoints would apply to specific content areas, such as sidebars, modal dialogs, or post bodies.
If you're able to work within the constraints of a mobile-first, breakpoint-based approach (which most developers currently do), then implementing a container-based fallback for that approach is significantly easier than implementing full support for every single container query feature.
The next section explains exactly how this all works, along with a step-by-step guide showing you how to implement it on an existing site.
How it works
Step 1: update your component styles to use @container
rules instead of @media
rules
In this first step, identify any components on your site that you think would benefit from container-based sizing rather than viewport-based sizing.
It's a good idea to start with just one or two components to see how this strategy works, but if you want to convert 100% of your components to container-based styling, that's fine too! The great thing about this strategy is you can adopt it incrementally if needed.
Once you've identified the components you want to update, you'll need to change each @media
rule in those components' CSS to an @container
rule.
Here's an example of how that might look on a .photo-gallery
component that, by default, is a single column, and then uses @media
rules to update its layout to become two and three columns in the MD and XL breakpoints (respectively):
.photo-gallery {
display: grid;
grid-template-columns: 1fr;
}
/* Styles for the `MD` breakpoint */
@media (min-width: 800px) {
.photo-gallery {
grid-template-columns: 1fr 1fr;
}
}
/* Styles for the `XL` breakpoint */
@media (min-width: 1200px) {
.photo-gallery {
grid-template-columns: 1fr 1fr 1fr;
}
}
To update the .photo-gallery
component to use @container
rules, first replace the string @media
with the string @container
in the CSS. The grammar for these two rules is similar enough that, in many cases, this may be all you need to change.
Depending on the design of your site, you may also need to update the size condition, especially if your site's @media
rules are making certain assumptions about how much space will be available to specific components at various viewport sizes.
For example, if the styles for the .photo-gallery
CSS at the MD
and XL
breakpoints in previous example assume that a 200 pixel-wide sidebar will be displayed at those breakpoints, then the size conditions for the @container
rules should be around 200 pixels less—assuming that the "container" element for the .photo-gallery
component won't include the sidebar.
Altogether, to convert the .photo-gallery
CSS from @media
rules to @container
rules, the full set of changes are as follows:
/* Before, using the original breakpoint sizes: */
@media (min-width: 800px) { /* ... */ }
@media (min-width: 1200px) { /* ... */ }
/* After, with the breakpoint sizes reduced by 200px: */
@container (min-width: 600px) { /* ... */ }
@container (min-width: 1000px) { /* ... */ }
Note that you don't have to change any of the styles within the declaration block, as those reflect how the component looks rather than when specific styles should apply.
Once you've updated your components styles from @media
rules to @container
rules, the next step is to configure your container elements.
Step 2: add container elements to your HTML
The previous step defined component styles that are based on the size of a container element. The next step is to define which elements on your page should be those container elements whose size the @container
rules will be relative to.
You can declare any element to be a container element in CSS by setting its container-type
property to either size
or inline-size
. If your container rules are width-based, then inline-size
is generally what you want to use.
Consider a site with the following basic HTML structure:
<body>
<div class="sidebar">...</div>
<div class="content">...</div>
</body>
To make the .sidebar
and .content
elements on this site containers, add this rule to your CSS:
.content, .sidebar {
container-type: inline-size;
}
For browsers that support container queries, this CSS is all you need to make the component styles defined in the previous step relative to either the main content area or the sidebar, depending on which element they happen to be within.
However, for browsers that don't support container queries, there's some additional work to do.
You need to add some code that detects when the size of the container elements changes and then updates the DOM based on those changes in a way that your CSS can hook into.
Fortunately, the code required to do that is minimal, and it can be completely abstracted away into a shared component that you can use on any site and in any content area.
The following code defines a reusable <responsive-container>
element that automatically listens for size changes and adds breakpoint classes that your CSS can style based on:
// A mapping of default breakpoint class names and min-width sizes.
// Redefine these (or add more) as needed based on your site's design.
const defaultBreakpoints = {SM: 400, MD: 600 LG: 800, XL: 1000};
// A resize observer that monitors size changes to all <responsive-container>
// elements and calls their `updateBreakpoints()` method with the updated size.
const ro = new ResizeObserver((entries) => {
entries.forEach((e) => e.target.updateBreakpoints(e.contentRect));
});
class ResponsiveContainer extends HTMLElement {
connectedCallback() {
const bps = this.getAttribute('breakpoints');
this.breakpoints = bps ? JSON.parse(bps) : defaultBreakpoints;
this.name = this.getAttribute('name') || '';
ro.observe(this);
}
disconnectedCallback() {
ro.unobserve(this);
}
updateBreakpoints(contentRect) {
for (const bp of Object.keys(this.breakpoints)) {
const minWidth = this.breakpoints[bp];
const className = this.name ? `${this.name}-${bp}` : bp;
this.classList.toggle(className, contentRect.width >= minWidth);
}
}
}
self.customElements.define('responsive-container', ResponsiveContainer);
This code works by creating a ResizeObserver that automatically listens for size changes to any <responsive-container>
elements in the DOM. If the size change matches one of the defined breakpoint sizes, then a class with that breakpoint name is added to the element (and removed if the condition no longer matches).
For example, if the width
of the <responsive-container>
element is between 600 and 800 pixels (based on the default breakpoint values set in the code), then the SM
and MD
classes will be added, like this:
<responsive-container class="SM MD">...</responsive-container>
These classes allow you to define fallback styles for browsers that don't support container queries (see step 3: add fallback styles to your CSS).
To update the previous HTML code to use this container element, change the sidebar and main content <div>
elements to be <responsive-container>
elements instead:
<body>
<responsive-container class="sidebar">...</responsive-container>
<responsive-container class="content">...</responsive-container>
</body>
In most situations, you can just use the <responsive-container>
element without any customization, but if you do need to customize it, the following options are available:
- Custom breakpoint sizes: This code uses a set of default breakpoint class names and min-width sizes, but you change these defaults to be whatever you like. You can also override these values on a per-element basis using the
breakpoints
attribute. - Named containers: This code also supports named containers by passing a
name
attribute. This can be important if you need to nest container elements. See the limitations section for more details.
Here is an example that sets both of these configuration options:
<responsive-container
name='sidebar'
breakpoints='{"bp4":400,"bp5":500,"bp6":600,"bp7":700,"bp8":800,"bp9":900,"bp10":1000}'>
</responsive-container>
Finally, when bundling this code, make sure you use feature detection and dynamic import()
to only load it if the browser doesn't support container queries.
if (!CSS.supports('container-type: inline-size')) {
import('./path/to/responsive-container.js');
}
Step 3: add fallback styles to your CSS
The last step in this strategy is to add fallback styles for browsers that don't recognize the styles defined in the @container
rules. Do this by duplicating those rules using the breakpoint classes that get set on the <responsive-container>
elements.
Continuing with the .photo-gallery
example from before, the fallback styles for the two @container
rules might look like this:
/* Container query styles for the `MD` breakpoint. */
@container (min-width: 600px) {
.photo-gallery {
grid-template-columns: 1fr 1fr;
}
}
/* Fallback styles for the `MD` breakpoint. */
@supports not (container-type: inline-size) {
:where(responsive-container.MD) .photo-gallery {
grid-template-columns: 1fr 1fr;
}
}
/* Container query styles for the `XL` breakpoint. */
@container (min-width: 1000px) {
.photo-gallery {
grid-template-columns: 1fr 1fr 1fr;
}
}
/* Fallback styles for the `XL` breakpoint. */
@supports not (container-type: inline-size) {
:where(responsive-container.XL) .photo-gallery {
grid-template-columns: 1fr 1fr 1fr;
}
}
In this code, for each @container
rule there is an equivalent rule conditionally matching the <responsive-container>
element if the corresponding breakpoint class is present.
The portion of the selector matching the <responsive-container>
element is wrapped in a :where() functional pseudo-class selector, to keep the specificity of the fallback selector equivalent to the specificity of the original selector within the @container
rule.
Each fallback rule is also wrapped in an @supports
declaration. While this is not strictly necessary for the fallback to work, it means that the browser completely ignores these rules if it supports container queries, which can improve style matching performance in general. It also potentially allows build tools or CDNs to strip those declarations if they know the browser supports container queries and doesn't need those fallback styles.
The main downside of this fallback strategy is it requires you to repeat style declaration twice, which is both tedious and error prone. However, if you're using a CSS preprocessor, you can abstract that into a mixin that generates both the @container
rule and the fallback code for you. Here's an example using Sass:
@use 'sass:map';
$breakpoints: (
'SM': 400px,
'MD': 600px,
'LG': 800px,
'XL': 1000px,
);
@mixin breakpoint($breakpoint) {
@container (min-width: #{map.get($breakpoints, $breakpoint)}) {
@content();
}
@supports not (container-type: inline-size) {
:where(responsive-container.#{$breakpoint}) & {
@content();
}
}
}
Then, once you have this mixin, you could update the original .photo-gallery
component styles to something like this, which eliminates the duplication entirely:
.photo-gallery {
display: grid;
grid-template-columns: 1fr;
@include breakpoint('MD') {
grid-template-columns: 1fr 1fr;
}
@include breakpoint('XL') {
grid-template-columns: 1fr 1fr 1fr;
}
}
And that's all there is to it!
Recap
So, to recap, here's how to update your code to use container queries now with a cross browser fallback.
- Identity components that you want to style relative to their container, and update the
@media
rules in their CSS to use@container
rules. Also (if you're not already), standardize on a set of breakpoint names to match the size conditions in your container rules. - Add the JavaScript that powers the custom
<responsive-container>
element, and then add the<responsive-container>
element to any content areas in your page that you want your components to be relative to. - To support older browsers, add fallback styles to your CSS that match against the breakpoint classes that get automatically added to the
<responsive-container>
elements in your HTML. Ideally use a CSS preprocessor mixin to avoid having to write the same styles twice.
The great thing about this strategy is there is a one-time setup cost, but after that it doesn't take any additional effort to add new components and define container-relative styles for them.
Seeing it in action
Probably the best way to understand how all these steps fit together is to see a demo of it in action.
This demo is an updated version of a site created in 2019 (before container queries existed) to help illustrate why container queries are essential to building truly responsive component libraries.
Since this site already had styles defined for a bunch of "responsive components", it was a perfect candidate to test out the strategy introduced here on a non-trivial site. Turns out, it was actually quite simple to update and required almost no changes to the original site styles.
You can check out the full demo source code on GitHub, and be sure to look specifically at the demo component CSS, to see how the fallback styles are defined. If you want to test out just the fallback behavior, there's a fallback-only demo that includes just that variant—even in browsers that support container queries.
Limitations and potential improvements
As mentioned at the beginning of this post, the strategy outlined here works well for the majority of use cases that developers actually care about when reaching for container queries.
That said, there are some more advanced use cases that this strategy intentionally does not attempt to support, addressed next:
Container query units
The specification for container queries defines a number of new units, that are all relative to the container's size. While potentially useful in some cases, the majority of responsive designs can likely be achieved through existing means, such as percentages or using grid or flex layouts.
That said, if you do need to use container query units, you could easily add support for them using custom properties. Specifically, by defining a custom property for each unit used on the container element, like this:
responsive-container {
--cqw: 1cqw;
--cqh: 1cqh;
}
And then whenever you need to access the container query units, use those properties, rather than using the unit itself:
.photo-gallery {
font-size: calc(10 * var(--cqw));
}
Then, to support older browsers, set the values for those custom properties on the container element within the ResizeObserver
callback.
class ResponsiveContainer extends HTMLElement {
// ...
updateBreakpoints(contentRect) {
this.style.setProperty('--cqw', `${contentRect.width / 100}px`);
this.style.setProperty('--cqh', `${contentRect.height / 100}px`);
// ...
}
}
This effectively lets you "pass" those values from JavaScript to CSS, and then you have the full power of CSS (for example, calc()
, min()
, max()
, clamp()
) to manipulate them as needed.
Logical properties and writing mode support
You may have noticed the use of inline-size
rather than width
in the @container
declarations in some of these CSS examples. You may have also noticed the new cqi
and cqb
units (for inline and block sizes, respectively). These new features reflect CSS's shift to logical properties and values rather than physical or directional ones.
Unfortunately, APIs like Resize Observer still report values in width
and height
, so if your designs need the flexibility of logical properties, then you need to figure that out for yourself.
While it's possible to get the writing mode using something like getComputedStyle()
passing in the container element, doing so has a cost, and there's not really a good way to detect if the writing mode changes.
For this reason, the best approach is for the <responsive-container>
element itself to accept a writing mode property that the site owner can set (and update) as needed. To implement this, you'd follow the same approach shown in the previous section, and swap width
and height
as needed.
Nested containers
The container-name
property lets you give a container a name, which you can then reference in an @container
rule. Named containers are useful if you have containers nested within containers and you need certain rules to only match certain containers (not just the nearest ancestor container).
The fallback strategy outlined here uses the descendant combinator to style elements matching certain breakpoint classes. This can break if you have nested containers, since any number of breakpoint classes from multiple container element ancestors could match a given component at the same time.
For example, here there are two <responsive-container>
elements wrapping the .photo-gallery
component, but since the outer container is larger than the inner container, they have different breakpoint classes added.
<responsive-container class="SM MD LG">
...
<responsive-container class="SM">
...
<div class="photo-gallery">...</div class="photo-gallery">
</responsive-container>
</responsive-container>
In this example, the MD
and LG
class on the outer container would affect the style rules matching the .photo-gallery
component, which doesn't match the behavior of container queries (since they only match against the nearest ancestor container).
To deal with this, either:
- Make sure you always name any containers that you're nesting, and then ensure your breakpoint classes are prefixed with that container name to avoid clashes.
- Use the child combinator instead of the descendant combinator in your fallback selectors (which is a bit more limiting).
The nested containers section of the demo site has an example of this working using named containers, along with the Sass mixin it uses in the code to generate the fallback styles for both named and unnamed @container
rules.
What about browsers that don't support :where()
, Custom Elements, or Resize Observer?
While these APIs may seem relatively new, they've all been supported in all browsers for more than three years, and they're all part of Baseline widely available.
So unless you have data showing that a significant portion of your site's visitors are on browsers that don't support one of these features, then there is no reason not to freely use them without a fallback.
Even then, for this specific use case, the worst that could happen is the fallback won't work for a very small percentage of your users, which means they'll see the default view rather than a view optimized for the container size.
The functionality of the site should still work, and that's what really matters.
Why not just use a container query polyfill?
CSS features are notoriously difficult to polyfill, and generally require re-implementing the brower's entire CSS parser and cascade logic in JavaScript. As a result, CSS polyfill authors have to make many tradeoffs that almost always come with numerous feature limitations as well as significant performance overhead.
For these reasons, we generally don't recommend using CSS polyfills in production, including the container-query-polyfill from Google Chrome Labs, which is no longer maintained (and was primarily intended for demo purposes).
The fallback strategy discussed here has fewer limitations, requires far less code, and will perform significantly better than any container query polyfill ever could.
Do you even need to implement a fallback for older browsers?
If you are concerned about any of the limitations mentioned here, it's probably worth asking yourself whether you actually need to implement a fallback in the first place. After all, the easiest way to avoid these limitations is to just use the feature without any fallbacks. Honestly, in many cases, that may be a perfectly reasonable choice.
According to caniuse.com, container queries are supported by 90% of global internet users, and for many people reading this post, the number is likely quite a bit higher for their user base. So it's important to keep in mind that most of your users will see the container-query version of your UI. And for the 10% of users that won't, it's not like they're going to have a broken experience. When following this strategy, in the worst case these users will see the default or "mobile" layout for some components, which is not the end of the world.
When making tradeoffs, it's a good practice to optimize for the majority of your users—rather than defaulting to a lowest-common-denominator approach that gives all users a consistent, but sub-par, experience.
So before you assume that you can't use container queries due to lack of browser support, actually take the time to consider what the experience would be like if you did choose to adopt them. The tradeoff may be well worth it, even without any fallbacks.
Looking forward
Hopefully this post has convinced you that it is possible to use container queries in production now, and that you don't have to wait for years until all non-supporting browsers completely disappear.
While the strategy outlined here does require a bit of extra work, it should be simple and straightforward enough that most people can adopt it on their sites. That said, there is certainly room to make it even easier to adopt. One idea would be to consolidate a lot of the disparate parts into a single component—optimized for a specific framework or stack—that handles all of the glue work for you. If you build something like this, let us know and we can help promote it!
Lastly, beyond container queries, there are so many amazing CSS and UI features that are now interoperable across all major browser engines. As a community, let's figure out how we can actually use those features now, so our users can benefit.
Update (July 25, 2024): originally the guidance in "Step 1" suggested that media queries and container queries could use the same size conditions. This is often true but not always (as some reasons rightly pointed out). The updated guidance now clarifies this and offers example cases where the size conditions might need to change.
Update (July 2, 2024): originally all of the CSS code examples used Sass (for consistency with the final recommendation). Based on feedback from readers, the first few CSS have been updated to plain CSS, and Sass is only used in the code samples that require the use of mixins.