Handling Container Query Fallbacks for Older Browsers

Problem statement

You have a component that reshapes itself according to its own width — a card that goes from stacked to side-by-side, a nav that collapses, a data row that becomes a definition list. Container queries express this exactly, and in a current engine it works perfectly. The narrow problem is what a visitor sees on an engine that predates the feature: a locked-down enterprise Chrome build, an in-app WebView on an old Android device, an iPad stuck on iOS 15. Those browsers do not error; they silently discard every @container block, so the component freezes in whatever state your unguarded baseline left it. If your baseline was written as the wide layout, that visitor gets a two-column card crushed into a phone-width column. This page is about choosing what those users should get instead, translating your component thresholds into viewport ones without the two paths drifting apart, and deciding honestly whether a polyfill earns its cost. It extends the Container Query Fallbacks guide within Mastering Container Queries & Responsive Layouts.


Approach rationale: pick the fallback before you write it

The instinct is to jump straight to code, but the design decision comes first, and there are only three defensible answers.

Do nothing beyond a good baseline. Write the component's mobile-first, single-column form unguarded, and put every enhancement inside the container query. An unsupporting engine then gets a stacked component at every width. This is plainer, never broken, and costs zero extra bytes. For the large majority of components — cards, media objects, form rows, list items — this is the right answer and you should stop here.

Map the container thresholds onto viewport thresholds. When the wide layout carries information the stacked one genuinely loses, a @media fallback approximates the container query using the viewport as a proxy for the component's width. The approximation holds only where the component's width is a predictable function of the viewport's, which is true for a main content column and false for anything that appears in both a sidebar and a full-width slot.

Polyfill. A script observes each container with ResizeObserver and toggles classes. It is the only option that reproduces true container behaviour, and it is the most expensive: a network request, main-thread work proportional to container count, and — the part that matters most — a reflow that happens after first paint, so the component visibly jumps. For a user on the kind of old, slow device that lacks container queries in the first place, that jump is the worst of both worlds. Reserve it for cases where a component legitimately appears at several unrelated widths on one page and the viewport cannot distinguish between them.

The accessibility calculus favours the first two. A stable, plain layout that is correct at first paint serves a screen-reader or keyboard user better than a sophisticated one that rearranges itself a second later. Layout that settles after paint also moves the tap target someone was already reaching for.


Key technique: the chrome budget between viewport and container

Where mapped fallbacks go wrong is arithmetic, not syntax. A container query threshold measures the component's own content box; a media query threshold measures the viewport. The difference between them is every fixed horizontal cost in between — page gutters, a sidebar, grid gaps, the component's own padding and borders. Call that total the chrome budget. If the container flips at 460px and the chrome budget is 96px, the equivalent viewport threshold is 556px, not 460px.

Get this wrong in one direction and the fallback flips to the wide layout while the component is still narrow, overflowing it. Get it wrong in the other and the component stays stacked long after there was room, which is merely plain and therefore the safer error to make. Round the budget up, not down.

Mapping a container threshold onto a viewport threshold A viewport bar showing fixed gutters and a sidebar subtracted from the total width, leaving the component width that a container query measures. viewport threshold = container threshold + chrome viewport 556px 24 gap 48 container 460px 24 chrome budget: 24 + 48 + 24 = 96px @container 460px @media 556px round the budget up — flipping late is plain, flipping early overflows

Complete working implementation

This file ships one component with both paths. The stacked baseline is unguarded so every engine gets it. The @media fallback carries the mapped threshold and is written so it only ever adds the wide layout. The container path is gated by @supports, whose mechanics are covered in the guide to feature detection with @supports, and its first job is to neutralise the media fallback so the two never both apply.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
  /* Thresholds declared once so the two paths cannot drift apart. */
  :root {
    --card-flip: 460px;   /* the component width the design flips at */
    --chrome: 96px;       /* page gutters + grid gap + card padding */
  }

  body {
    margin: 0;
    font-family: system-ui, sans-serif;
    background: #0d1017;
    color: #e8ecf4;
  }

  /* Page shell contributing the fixed costs counted in --chrome. */
  .page { padding: 24px; display: grid; gap: 48px; }

  /* ---- Baseline: the fallback design, not a broken half-state. -------- */
  .card {
    display: grid;
    grid-template-columns: 1fr;   /* stacked */
    gap: 1rem;
    padding: 12px;
    background: #11141c;
    border-radius: 12px;
  }
  .card img {
    width: 100%;
    aspect-ratio: 4 / 3;          /* reserves height in BOTH paths */
    object-fit: cover;
    border-radius: 8px;
  }
  .card h2 { margin: 0 0 0.25rem; font-size: 1.1rem; }
  .card p  { margin: 0; color: #aab4c8; }

  /* ---- Viewport fallback: container threshold + chrome budget. --------
     calc() runs at used-value time, so the two custom properties above
     stay the single source of truth for both paths.                    */
  @media (min-width: calc(460px + 96px)) {
    .card {
      grid-template-columns: 200px 1fr;
      align-items: start;
    }
  }

  /* ---- Modern path. Engines without container queries never enter. --- */
  @supports (container-type: inline-size) {
    .card-wrap { container: card / inline-size; }

    /* Undo the viewport fallback first: on a wide screen it has already
       applied, and a NARROW container must be able to pull it back.     */
    .card { grid-template-columns: 1fr; }

    @container card (min-width: 460px) {
      .card {
        grid-template-columns: 200px 1fr;
        align-items: start;
      }
    }
  }
</style>
</head>
<body>
  <div class="page">
    <div class="card-wrap">
      <article class="card">
        <img src="https://example.com/ridge.jpg" alt="Ridge trail at dawn">
        <div>
          <h2>Northern ridge route</h2>
          <p>Modern engines lay this card out by its own width. Older engines
             use the mapped viewport threshold. Neither state is broken.</p>
        </div>
      </article>
    </div>
  </div>
</body>
</html>

Two details are load-bearing. The aspect-ratio on the image sits in the baseline, outside every conditional, so the box reserves its height identically in both paths — a fallback that reserves different space is a fallback that introduces layout shift on exactly the slow devices least able to absorb it. And the grid-template-columns: 1fr reset at the top of the @supports block exists because a supporting browser reads both the media query and the container query. On a 900px screen the media fallback has already applied the two-column layout; without the reset, a card sitting in a 300px sidebar could not get back to stacked, because both rules have equal specificity.

Note also that the threshold literals appear in calc(460px + 96px) rather than as var() references. Media query conditions are evaluated too early in the cascade to read custom properties, which is a genuine limitation of the mapping approach: you can document the arithmetic in custom properties for the container path and for maintainers, but the media query itself needs the literals. Keeping both in one :root block at the top of the file is the practical way to stop them diverging.


Variation: components that appear in two slots

The mapping breaks the moment one component is used at two unrelated widths on the same page — say a card that appears both in a full-width feed and in a 280px sidebar. The viewport cannot tell those apart, so a single mapped threshold is wrong for one of them. The fix is not a polyfill but a slot-scoped fallback: let the context declare which mapping applies, since the context does know its own width.

/* Each slot opts into the fallback mapping that suits it. The sidebar
   opts out entirely — it is never wide enough to flip. */
@supports not (container-type: inline-size) {
  @media (min-width: 556px) {
    .feed .card {
      grid-template-columns: 200px 1fr;
      align-items: start;
    }
  }
  /* .sidebar .card gets no rule at all: permanently stacked, correct. */
}

/* Right-to-left needs nothing extra: the track list is order-based and
   grid reverses it automatically under dir="rtl". Only the flow of the
   text block needs a logical property rather than a physical one. */
.card p { padding-inline-end: 0.5rem; }

This is the general shape of a good degradation strategy: rather than trying to reproduce container behaviour, reduce the number of contexts the fallback has to be correct in, and let contexts that will never cross the threshold opt out. It scales down to nothing, needs no script, and it is far easier to review than a mapping table. The same reasoning applies to container query units, where the fallback is simply a static value declared before the cqi one.


Browser support note

Size container queries are baseline across current engines: Chrome and Edge 105+ (September 2022), Safari 16.0+, and Firefox 110+ (February 2023). The population still lacking them in 2026 is therefore Chrome and Edge 104 and below, Safari 15 and below — notably iOS 15, which is the ceiling for a number of older iPads — and Firefox 109 and below, plus Android System WebView builds that trail their host OS. The supporting mechanisms this page relies on are older and safer still: @media (min-width:) and calc() inside a media condition work everywhere, and aspect-ratio (Chrome 88+, Safari 15+, Firefox 89+) covers slightly more of the fallback population than container queries do, so pair it with the padding-top hack described in the guide to aspect-ratio for responsive media if your floor is lower.


FAQ

What should the fallback layout actually be? In most cases the single-column baseline, unchanged. A component that stacks is never wrong, only plainer. Reach for a mapped viewport breakpoint only when the wide layout carries information the stacked one loses, such as a comparison table.

How do I choose the viewport breakpoint that matches a container breakpoint? Add up every fixed horizontal cost between the viewport edge and the component box: page padding, sidebar width, grid gaps, and the component's own padding and borders. The viewport threshold is the container threshold plus that total.

Do I need a polyfill if I already have a viewport fallback? Almost never. A polyfill adds a script dependency, ResizeObserver work on every container, and a visible reflow after first paint. It is only justified when a component appears at several unrelated widths on the same page and the viewport cannot distinguish them.

Why does my @container rule get ignored even in a modern browser? Almost always a missing container-type on the queried ancestor, or a query pointed at a container-name that is not on any ancestor. An element cannot query itself, so the container-type must sit on a wrapper, not on the element the rule targets.


Related articles

More pages in the same section.