Fallback Strategies for Container Query Support

When a browser does not understand @container, it does not ignore the query and keep the declarations — it discards the entire block, every rule inside it, silently and with no console warning. A media card whose crop ratio, object-position and padding all live inside @container therefore renders with none of them: an untouched intrinsic-ratio image in a box with no reserved height. This page builds a fallback that degrades in defined tiers instead, extending the containment mechanics in CSS Container Queries for Dynamic Media Sizing under Responsive Image & Video Delivery. There is no build-time escape hatch: @container resolves against runtime layout, so no PostCSS transform can lower it to @media.

Prerequisite checklist

Why the failure is total rather than partial

CSS error recovery works at two different granularities, and container queries land on the destructive side of the line. An unrecognised declaration is dropped on its own — the surrounding rule still applies everything else. An unrecognised at-rule is consumed together with its whole {} block, so every nested rule inside vanishes.

Declaration-level versus at-rule-level error recovery Two side by side parser paths. On the left an unknown declaration inside a known rule is dropped alone and the remaining three declarations still apply. On the right an unknown at-container rule is consumed to its matching closing brace, so all four declarations inside it are discarded and nothing survives. Unknown DECLARATION Unknown AT-RULE .media-card__image { width: 100%; contain-intrinsic-crop: 4 / 3; aspect-ratio: 16 / 9; object-fit: cover; } @container media-card (max-width: 359px) { .media-card__image { aspect-ratio: 4 / 3; object-position: 50% 25%; } } parser skips to the next semicolon 3 of 4 declarations still apply parser consumes to the matching brace 0 of 4 declarations survive Consequence: anything the card NEEDS in order to look acceptable must live outside every @container block. Neither case logs a warning. DevTools shows the at-rule greyed out only if you already know where to look.

The practical rule this yields: @container may only ever upgrade an already-acceptable rendering. If deleting all your container queries leaves the card broken, your baseline is wrong regardless of which fallback technique you bolt on afterwards.

Exact solution: three tiers on one cascade order

The stylesheet below is complete. @layer fixes the precedence once at the top, which removes the usual specificity arms race between a fallback and its enhancement — the enhanced layer wins even though .media-card[data-cq="wide"] in bridge is the more specific selector.

/* card-media.css — one file, three tiers, one declared cascade order. */

/* Layer order is fixed HERE and nowhere else. Later layers beat earlier
   layers regardless of selector specificity or source position, so the
   fallback can use whatever selectors it likes without shadowing tier 2. */
@layer baseline, bridge, enhanced;

/* ── Tier 0 · baseline ──────────────────────────────────────────────
   Plain CSS understood by every engine back to Safari 12. No @supports,
   no @container. This is what iOS 15 and Firefox ESR 102 actually get. */
@layer baseline {
  .media-card { --card-ratio: 16 / 9; }

  .media-card__image {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: var(--card-ratio);  /* reserves the slot → zero CLS in EVERY tier */
    object-fit: cover;
    object-position: 50% 35%;
  }

  /* Best available guess without container data: the page grid only puts
     cards in the narrow rail below 1024px, so bias the crop there. This is
     an approximation, not a substitute — it is wrong for a card dropped in
     a modal, and that is an acceptable tier-0 outcome. */
  @media (max-width: 1023px) { .media-card { --card-ratio: 4 / 3; } }
}

/* ── Tier 1 · bridge ────────────────────────────────────────────────
   Consumed only if the script below stamps data-cq. Attribute selectors
   match nothing until then, so this layer is free on browsers that never
   run the bridge and free on browsers that never need it. */
@layer bridge {
  .media-card[data-cq="compact"]  { --card-ratio: 4 / 3;  }
  .media-card[data-cq="standard"] { --card-ratio: 16 / 9; }
  .media-card[data-cq="wide"]     { --card-ratio: 21 / 9; }
  .media-card[data-cq="compact"] .media-card__image { object-position: 50% 25%; }
}

/* ── Tier 2 · enhanced ──────────────────────────────────────────────
   @supports is the gate. An engine without container queries evaluates the
   condition to false and skips the block wholesale — including the
   container-type declaration, so no half-configured state can exist. */
@supports (container-type: inline-size) {
  @layer enhanced {
    .media-card {
      container-type: inline-size;
      /* ALWAYS name it. Safari 16.0 shipped container queries with a bug
         where an unnamed @container resolves against the OUTERMOST
         container ancestor; @supports reports true there, so feature
         detection will not save you. Naming every container does. */
      container-name: media-card;
    }

    @container media-card (max-width: 359px) {
      .media-card { --card-ratio: 4 / 3; }
      .media-card__image { object-position: 50% 25%; }
    }
    @container media-card (min-width: 640px) {
      .media-card { --card-ratio: 21 / 9; }
    }
  }
}
// cq-bridge.js — tier 1. Load as a plain <script src> at the END of <head>
// (no defer, no module) so the detection branch settles before first paint.
// ~600 bytes minified; it exits immediately on every modern browser.
(() => {
  // Single source of truth for the branch. CSS.supports is available in
  // every engine that could plausibly render this page (Safari 9+).
  if (window.CSS && CSS.supports('container-type: inline-size')) return;

  // Thresholds MUST mirror the @container breakpoints above. Keep them in
  // one place in your build (a JSON file consumed by both) if you can.
  const THRESHOLDS = [360, 640];
  const bucketFor = (w) => (w < 360 ? 'compact' : w < 640 ? 'standard' : 'wide');
  const HYSTERESIS = 2;  // px of slack so a fractional width cannot oscillate

  let queued = false;
  const pending = new Map();

  const flush = () => {
    queued = false;
    // Write in one rAF pass. Writing inside the observer callback risks the
    // "ResizeObserver loop completed with undelivered notifications" error
    // and, worse, an infinite resize→restyle→resize cycle.
    for (const [el, bucket] of pending) el.dataset.cq = bucket;
    pending.clear();
  };

  const ro = new ResizeObserver((entries) => {
    for (const entry of entries) {
      // contentBoxSize is the authoritative modern field; contentRect is the
      // legacy shape still emitted by the ResizeObserver polyfill.
      const w = entry.contentBoxSize
        ? entry.contentBoxSize[0].inlineSize
        : entry.contentRect.width;
      const el = entry.target;
      const next = bucketFor(w);
      const current = el.dataset.cq;
      // Skip no-op writes AND boundary jitter within the hysteresis band.
      if (next === current) continue;
      if (current && THRESHOLDS.some((t) => Math.abs(w - t) < HYSTERESIS)) continue;
      pending.set(el, next);
    }
    if (pending.size && !queued) { queued = true; requestAnimationFrame(flush); }
  });

  const attach = (root) =>
    root.querySelectorAll?.('.media-card')?.forEach((el) => ro.observe(el));

  // Cards that exist at parse time…
  document.addEventListener('DOMContentLoaded', () => attach(document));

  // …and cards injected later by client-side routing. Without this, an SPA
  // navigation renders unbucketed cards at the tier-0 ratio forever.
  new MutationObserver((records) => {
    for (const r of records) {
      r.addedNodes.forEach((n) => { if (n.nodeType === 1) attach(n); });
      // Unobserve removals: ResizeObserver holds a strong reference and will
      // leak one entry per destroyed card across a long SPA session.
      r.removedNodes.forEach((n) => {
        if (n.nodeType === 1) n.querySelectorAll?.('.media-card')?.forEach((el) => ro.unobserve(el));
      });
    }
  }).observe(document.documentElement, { childList: true, subtree: true });
})();

The layer diagram below is the whole design in one picture: which layers are populated, and which one wins, for each audience tier.

Which cascade layer paints the card, per audience tier Four columns for modern engines, Safari 16.0, legacy engines with the bridge script, and legacy engines with JavaScript disabled. Three rows for the baseline, bridge and enhanced layers. Filled cells show a populated layer and a marked cell shows the layer that actually paints. Modern engines and Safari 16.0 paint from enhanced, legacy plus bridge paints from bridge, and legacy without JavaScript paints from baseline. @layer baseline, bridge, enhanced — precedence increases downward Chrome 105+ / Safari 16.1+ / FF 110+ Safari 16.0 (names required) iOS 15 / ESR 102 + bridge script iOS 15, JS blocked or script failed baseline plain CSS + @media PAINTS · 4/3 or 16/9 bridge [data-cq] buckets never stamped never stamped PAINTS · 3 buckets no script enhanced @supports + @container PAINTS · continuous PAINTS · named only block discarded block discarded Every column paints something correct. No column paints two tiers at once, because layer order is absolute. The bytes downloaded are identical in all four columns — srcset and sizes are untouched by any tier.

Tradeoff: the bridge duplicates your breakpoint numbers in JavaScript. There is no way around it — CSS cannot expose a container query’s threshold to script. Generate both from one JSON file at build time if the component library is large enough that drift is realistic.

The polyfill option, and why it is usually the wrong tier

container-query-polyfill (roughly 9 KB gzipped) rewrites @container rules into :where()-scoped selectors keyed by custom properties, then drives them with its own ResizeObserver and MutationObserver. It is more faithful than a three-bucket bridge, and it costs materially more. The critical property is when it can act: it must parse your stylesheets, instrument every container, wait for a layout pass, and only then restyle. Everything before that moment paints with the container rules absent.

Correction latency: @supports baseline versus polyfill Two horizontal timelines from zero to nine hundred milliseconds on a throttled mobile profile. The baseline track paints the correct fallback ratio at 420 milliseconds and never changes. The polyfill track paints an unstyled ratio at 420 milliseconds, finishes parsing and instrumenting at 610 milliseconds, and repaints corrected at 640 milliseconds, producing a layout shift in the 220 millisecond window between. 0 ms 300 600 900 @supports baseline · no extra script HTML + CSS parse first paint · correct 420 ms · CLS 0.00 · stable from here container-query-polyfill · 9 KB gz HTML + CSS parse first paint · unstyled parse + instrument 640 ms · repaint corrected 220 ms window: wrong ratio on screen → recorded as layout shift Neither track changes which image file is fetched — the request left before either mechanism ran. Figures from a Moto G Power profile, Fast 3G, 12 cards per page.

Warning: the polyfill only reduces that window if it is loaded synchronously in <head>, which puts 9 KB of render-blocking JavaScript in front of first paint for every visitor unless you serve it conditionally. It also requires explicit container-name on each container, does not implement style() queries, and — like every fallback here — cannot influence resource selection, because the preload scanner has already dispatched the request. Reach for it only when the fallback fidelity genuinely matters to the product and the affected audience is large enough to justify the shift.

Verification

1. Confirm which tier the current browser is on

// Paste into the console on the real page.
console.table({
  cssSupportsAPI:  CSS.supports('container-type: inline-size'),
  // Computed `contain` proves the container-type declaration actually applied,
  // not merely that the parser accepted it (specificity, layers, resets).
  computedContain: getComputedStyle(document.querySelector('.media-card')).contain,
  bridgeStamped:   document.querySelector('.media-card')?.dataset.cq ?? '(none)',
  paintedRatio:    getComputedStyle(
                     document.querySelector('.media-card__image')).aspectRatio,
});
// Modern engines: contain includes "layout style inline-size", bridgeStamped "(none)".
// Legacy + bridge:  contain is "none", bridgeStamped is compact|standard|wide.

2. Quantify the audience actually on the fallback

# Browserslist understands caniuse feature queries. This prints every browser
# in your support target that LACKS container queries, with usage share.
npx browserslist "> 0.2% and not supports css-container-queries"

# Same question against your project's own .browserslistrc:
npx browserslist --coverage
npx browserslist "supports css-container-queries and > 0.2%" | wc -l

If the first command prints nothing, delete the bridge script: you are shipping and maintaining JavaScript for zero users. Re-run it in CI monthly rather than trusting a decision made two years ago.

3. Fail the build when a container query escapes its gate

// scripts/assert-cq-guarded.mjs — run in CI: node scripts/assert-cq-guarded.mjs
// Walks the built CSS and asserts every @container has an @supports ancestor.
import postcss from 'postcss';
import { readFile } from 'node:fs/promises';

const css = await readFile('dist/assets/card-media.css', 'utf8');
const root = postcss.parse(css);
const offenders = [];

root.walkAtRules('container', (rule) => {
  let node = rule.parent, guarded = false;
  while (node) {
    if (node.type === 'atrule' && node.name === 'supports' &&
        /container-type/.test(node.params)) { guarded = true; break; }
    node = node.parent;
  }
  if (!guarded) offenders.push(`${rule.source.start.line}: @container ${rule.params}`);
});

// Second assertion: no unnamed container query (the Safari 16.0 trap).
root.walkAtRules('container', (rule) => {
  if (/^\s*\(/.test(rule.params)) offenders.push(`${rule.source.start.line}: unnamed @container`);
});

if (offenders.length) {
  console.error('Ungated or unnamed container queries:\n' + offenders.join('\n'));
  process.exit(1);
}
console.log('All @container rules are gated and named.');

4. Exercise the fallback without an old browser

Add a dev-only ?cq=off flag that forces CSS.supports to report false for the bridge, then screenshot all three buckets. This is the only way to regression-test tier 1 on your own machine.

# Renders the same page in the forced-fallback path at three container widths.
for W in 300 500 820; do
  npx playwright screenshot \
    --viewport-size=1280,900 \
    "http://localhost:8080/cards/?cq=off&track=${W}px" "fallback-${W}.png"
done
# Compare each against the tier-2 render at the same track width; the crops
# should match at 300 and 820, and may differ inside the hysteresis band.

Common mistakes

1. Writing a negative guard with invalid syntax

supports() is not a media feature, so @media not (supports(container-type: inline-size)) is not valid CSS and the whole block is discarded — which is the exact failure the guard was meant to prevent.

/* WRONG: invalid at-rule prelude; the fallback never applies anywhere. */
@media not (supports(container-type: inline-size)) {
  .media-card__image { aspect-ratio: 16 / 9; }
}
/* CORRECT: @supports with a negation. An engine that has never heard of
   container-type evaluates the inner condition false, so `not` is true. */
@supports not (container-type: inline-size) {
  .media-card__image { aspect-ratio: 16 / 9; }
}
/* BETTER: skip negative guards entirely — put the fallback in an earlier
   cascade layer and let the enhancement override it unconditionally. */

2. Trusting @supports on Safari 16.0

Safari 16.0 reports container-type: inline-size as supported and then resolves unnamed @container rules against the outermost container ancestor instead of the nearest one. Feature detection cannot see the difference; a card nested inside a container-typed grid gets the grid’s width and flips to the wide crop at 300 px.

/* WRONG: unnamed query — correct in Chrome, wrong ancestor in Safari 16.0. */
@container (min-width: 640px) { .media-card { --card-ratio: 21 / 9; } }
/* CORRECT: name every container and reference the name in every query. */
.media-card { container-type: inline-size; container-name: media-card; }
@container media-card (min-width: 640px) { .media-card { --card-ratio: 21 / 9; } }

3. Deferring the bridge script

Loading cq-bridge.js with defer or from the end of <body> moves the bucket stamp after first paint, so legacy browsers paint tier 0, then jump to tier 1. Because aspect-ratio changes, that jump is a genuine layout shift attributed to your card. Load it at the end of <head> without defer; it exits in microseconds on supporting engines. The same before-first-paint reasoning governs placeholder and blur-up techniques in progressive rendering and image placeholders.

4. Letting the bridge resize its own observed element

If the bucket class changes something that alters the container’s inline size — a border, a horizontal padding, a width — the observer fires again, re-buckets, and you get an oscillation that Chrome reports as ResizeObserver loop completed with undelivered notifications.

// WRONG: the bucket adds inline padding, which changes the observed width.
el.dataset.cq = bucket;              // .media-card[data-cq] { padding-inline: 16px }
// CORRECT: only ever change block-axis or paint-only properties from a bucket
// (aspect-ratio, object-position, font-size), and observe a wrapper whose
// inline size is fixed by its ancestor grid track, never by its own content.
el.dataset.cq = bucket;              // .media-card[data-cq] { --card-ratio: … }

5. Expecting a fallback tier to restore the byte savings

No tier on this page changes currentSrc. Both the bridge and the polyfill act after the resource selection algorithm has run, so a legacy browser downloads exactly the same file a modern one does — decided by srcset and sizes alone. If your image budget depends on container-accurate widths, that budget must be met with explicit sizes arithmetic and enforced in CI, for example with Lighthouse CI budget enforcement for image weight. The same constraint applies to format fallbacks: a legacy engine that misses @container usually also misses AVIF, which is a separate negotiation covered in how to configure AVIF fallbacks for Safari 14.