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.
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.
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.
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.
Related
- CSS Container Queries for Dynamic Media Sizing — the containment model and build integration this fallback protects
- Container Queries vs Media Queries for Card Images — which decisions belong to each query type before you write a fallback at all
- How to Configure AVIF Fallbacks for Safari 14 — the format-negotiation fallback that pairs with this layout fallback
- Lighthouse CI Budget Enforcement for Image Weight — keeping the byte budget honest in every tier