Container queries in production: a pragmatic fallback strategy

Container queries are now supported in every major browser engine, yet adoption has been slow. Chris Coyier recently asked why developers aren't using them more. One common answer: teams still need to support older browsers and assume container queries must wait until that requirement goes away.

That assumption doesn't have to hold. For most projects, you can ship container queries today and still deliver a consistent experience in older browsers. The approach is to layer a targeted JavaScript fallback on top of browsers that lack native support.

Focusing the fallback on what developers actually use

The real question is how complete that fallback needs to be. Building a full polyfill for a CSS feature is usually impractical — the CSSOM surface is too large and the edge cases are endless. But you don't need full API coverage if you focus on the common patterns that most component-based design systems rely on.

Consider how responsive design works today. Most modern systems are built mobile-first using a set of fixed breakpoints like SM, MD, LG, and XL. Components are styled for small screens by default, then enhanced with conditional rules as more horizontal space becomes available. Bootstrap and Tailwind both document this workflow extensively.

That same mental model transfers directly to container queries. When a designer asks how a card, a form, or a nav should reflow, what matters isn't the viewport size — it's how much room the component has inside its placement context. A sidebar, a dialog, and a main content column can hit different breakpoints on the same screen. Container queries make those breakpoints apply to the container rather than the page.

The key insight is that the mobile-first breakpoint paradigm is exactly what you can support with a lightweight fallback. Instead of trying to emulate every container query possibility, you limit the scope to breakpoint-based container queries — the same abstraction developers already use with media queries. That constraint makes the fallback tractable.

Implementing the fallback

If you accept the breakpoint-based model, the fallback mechanics become straightforward. Here is the step-by-step approach to add container query support to a site that still has to serve older browsers:

  1. Start with your container elements. Mark the containers that can serve as query subjects, and for each one, define the breakpoints that matter for components inside it. If a container's width spans certain ranges, that container can inform which styles its children receive.
  2. Use JavaScript to measure those containers' dimensions once they're in the DOM. Observing every possible container is wasteful; instead, target only containers that actually contain query-dependent components.
  3. Apply a utility class or data attribute to the container element that encodes the current breakpoint state. For example, if the container is narrower than the MD threshold, add an is-sm class; when it crosses that threshold, swap to is-md.
  4. Author your CSS against those classes first — that becomes your universal baseline. Browsers without native container query support apply the class-based styles. Browsers with native support override them with container query rules using the same breakpoint values.
  5. Keep the two style paths in sync by using the same CSS custom properties or Sass variables for breakpoint values on both sides.

Because the fallback only cares about width thresholds on known elements, it stays performant. You never need to observe every resize or every element; you only evaluate the containers that own query-dependent components.

Why a full polyfill is the wrong trade-off

The temptation is to reach for a comprehensive polyfill that claims full support for the entire container query specification. That approach carries real cost: larger payloads, continuous mutation observation, and style recalculations on every DOM change. Even the best polyfill has to make assumptions about the timing of style application, and those assumptions can cause visual flashes or layout thrash that are hard to debug.

The benefit of the class-based fallback is that it falls back gracefully. If JavaScript fails or loads late, the mobile-first base styles still render correctly. The fallback only enhances the experience for the mid-range layouts. Because it mirrors how media queries already work, it also doesn't force a separate design system — the same breakpoint vocabulary applies in both worlds.

Moving forward without waiting

The barrier to container queries is not browser support alone — it's the perceived need for a perfect implementation. For the majority of components people want to build today, mobile-first breakpoint behavior covers the core use cases. A lean JavaScript enhancement for older browsers plus native container queries for modern ones gets you most of the benefit without deferring the work.

Teams that standardize on breakpoint-driven component styles now will find the transition incremental. You can adopt native container queries progressively, and the fallback keeps the experience consistent for users on legacy browsers. That removes the last good reason to keep container queries off your roadmap.

Switching component styles to @container

Start by picking the components that will benefit from sizing relative to their own container rather than the viewport. Begin with one or two to validate the approach; container-query adoption is fully incremental, so you can convert components gradually as needed.

For each selected component, change the @media rules in its CSS to @container rules. The grammar of both at-rules is close enough that, in many cases, replacing the at-rule name is the only required text change. For example, a .photo-gallery component that defaults to a single column and then expands to two and three columns at the MD and XL viewport breakpoints would look like this in its original @media form:

/* 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) { /* ... */ }

After conversion, the size conditions may need adjustment if your @media queries assumed certain page layout features—like a 200-pixel sidebar—that won't be part of the component's own container. In that case, reduce the query thresholds by roughly that amount. The style declarations inside the blocks stay untouched; they describe how the component renders, not when those rules should apply.

Once the component styles are using @container rules, you need to designate which elements on the page act as size references.

Declaring container elements

Any element can become a container by setting its container-type property to size or inline-size. For width-based queries, inline-size is the usual choice. Given HTML with a sidebar and main content area, you can turn both into containers with a single rule:

.content, .sidebar {
  container-type: inline-size;
}

For browsers that support container queries, this is sufficient to make the earlier @container rules responsive to the dimensions of whichever container element the component sits inside.

Providing a fallback for older browsers

Browsers that don't support container queries need a different mechanism to update the DOM when container sizes change, so the CSS can still respond. The following code defines a reusable <responsive-container> element. It uses a ResizeObserver to watch for size changes and add or remove breakpoint classes based on predefined thresholds:

// 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);

If a <responsive-container> is between 600 and 800 pixels wide (using the default breakpoints), the element receives both the SM and MD classes:

<responsive-container class="SM MD">...</responsive-container>

These classes provide the hook that fallback CSS can match against. To use the element, swap the sidebar and content <div> elements for <responsive-container> tags:

<body>
  <responsive-container class="sidebar">...</responsive-container>
  <responsive-container class="content">...</responsive-container>
</body>

The default behavior works in most cases, but two configuration options are available. You can override the breakpoint names and min-width sizes globally, or per element with the breakpoints attribute. Named containers are supported via a name attribute, which matters when container elements are nested. An example combining both options looks like:

<responsive-container
  name='sidebar'
  breakpoints='{"bp4":400,"bp5":500,"bp6":600,"bp7":700,"bp8":800,"bp9":900,"bp10":1000}'>
</responsive-container>

Bundle this JavaScript with feature detection and a dynamic import(), so it only loads when container queries are unsupported:

if (!CSS.supports('container-type: inline-size')) {
  import('./path/to/responsive-container.js');
}

Adding fallback styles

The fallback CSS duplicates each @container rule, but uses the breakpoint classes set by <responsive-container> as the condition. For the .photo-gallery example, those equivalents look like:

/* 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;
  }
}

Each fallback selector wraps the responsive-container portion in :where() to keep specificity equal to the original selector inside the @container rule. The rules are also inside an @supports block. That's optional, but it lets browsers that do support container queries ignore the fallback entirely, which improves style-matching performance and may allow build tools or CDNs to strip those declarations when they're known to be unnecessary.

The downside is that this duplicates every style declaration, which is tedious and error-prone. A CSS preprocessor can abstract this into a mixin that generates both the @container rule and its fallback. In Sass, that mixin looks like:

@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();
    }
  }
}

With the mixin defined, component styles can be rewritten to remove 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;
  }
}

Putting it together

  1. Identify the components that should size relative to their container, and convert @media rules to @container rules. Standardize breakpoint names for the conditions in those rules.
  2. Include the JavaScript that powers the custom <responsive-container> element, and wrap the content areas with that element so components query against them.
  3. For older browsers, add fallback styles that match the breakpoint classes automatically added to <responsive-container>. Ideally, generate those fallback styles with a CSS preprocessor mixin to avoid writing the same declarations twice.

The strategy has a one-time setup cost; after that, adding new container-relative components takes no extra effort.

A live demo shows the whole approach on a site originally built in 2019, before container queries existed, to illustrate the limitations of viewport-based responsive components. Updating it required almost no changes to the original styles. The demo component CSS shows how fallback styles are organized, and there's a fallback-only variant that exercises just the fallback path even in browsers that support container queries.

Where the fallback approach hits its limits

The strategy outlined in this series covers the vast majority of real-world container query use cases. There are, however, several advanced scenarios it does not attempt to handle, and it's worth understanding each before adopting the technique.

Container query units

The container queries specification defines length units such as cqi and cqb that are relative to the container's inline and block sizes. Many responsive designs can get by with standard CSS tools — percentages, flexbox, or grid — so these units aren't always necessary.

If you do need container query units, you can emulate them with custom properties set on the container element:

responsive-container {
  --cqw: 1cqw;
  --cqh: 1cqh;
}

Then reference those properties anywhere you would have used the unit:

.photo-gallery {
  font-size: calc(10 * var(--cqw));
}

For older browsers, populate those custom properties inside 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 passes the container's size from JavaScript into CSS, where you can still use functions like calc(), min(), max(), and clamp().

Logical properties and writing modes

The native @container syntax supports inline-size and cqb/cqi units, reflecting CSS's pivot toward logical properties. ResizeObserver, however, only reports physical width and height. Bridging that gap for writing-mode-sensitive layouts is awkward: detecting the writing mode via getComputedStyle() is costly, and there's no clean event for changes to it.

The practical workaround is to let the <responsive-container> element accept its own writing mode property, which the site owner sets and updates as needed. From there, the implementation follows the same pattern as the container unit approach: swap which physical dimension maps to inline versus block size based on that property.

Nested containers

Nested containers present a genuine problem for the fallback. Native container queries always resolve against the nearest ancestor container. The fallback's descendant combinators, by contrast, match whatever breakpoint classes happen to exist on any ancestor container.

Consider two nested <responsive-container> elements around a .photo-gallery component:

<responsive-container class="SM MD LG">
  ...
  <responsive-container class="SM">
    ...
    <div class="photo-gallery">...</div class="photo-gallery">
  </responsive-container>
</responsive-container>

The outer container is larger, so it carries its own breakpoint classes (say, MD and LG). Those classes then incorrectly influence the rules targeting .photo-gallery, which should only respond to the inner container.

Two workable solutions exist:

  1. Name your nested containers and prefix their breakpoint classes with that name to prevent collisions.
  2. Switch from the descendant combinator to the child combinator in fallback selectors, accepting the reduced matching flexibility.

For a worked example with named containers, the container queries demo site shows a Sass mixin that generates fallback styles for both named and unnamed @container rules.

On browser support, polyfills, and tradeoffs

If you're worried about browsers that lack :where(), Custom Elements, or ResizeObserver: those APIs have been baseline-supported for over three years. Unless your analytics show significant traffic on browsers missing them, there's no reason to add fallbacks. Worst case, a small cohort sees the default layout — the site's functionality remains intact.

CSS polyfills are a different story. They generally force the browser's entire parsing and cascade logic into JavaScript, which yields feature gaps and performance penalties. The container-query-polyfill from Google Chrome Labs illustrates this tradeoff; it was demo-focused and is no longer maintained. The fallback approach here requires far less code and executes much faster than a polyfill ever could.

Before committing to any fallback, reconsider whether you need one. Caniuse reports container queries work for roughly 90% of global users; for many sites that number is even higher. Most of your audience will see the container-query UI. The remaining users get a mobile/default view rather than a broken page. Building for the majority instead of the lowest common denominator is a reasonable engineering choice.

The point is not that you must adopt the fallback. The point is that you should evaluate container queries on real tradeoffs rather than assuming the lack of universal support precludes their use. Used judiciously, container queries are production-ready today.