Feature Detection with @supports: Syntax, Semantics, and the Traps
Problem statement
You want to ship a CSS feature that some of your traffic cannot render, and you want the decision made by the browser at parse time rather than by a user-agent string, a build flag, or a script that runs after first paint. CSS gives you exactly one native mechanism for that: the @supports conditional group rule. It looks trivial — wrap a block in a parenthesised test and it applies only where the test passes — and that apparent simplicity is why it is so often misused. Authors gate on the wrong token, assume a passing test means the feature behaves correctly, negate a condition and accidentally exclude the browsers they were trying to help, or reach for a probe that is true everywhere and therefore tests nothing. This page is about the mechanism itself: what @supports actually evaluates, its full grammar, and the specific false assumptions that turn a feature gate into a silent bug. It sits inside Container Query Fallbacks, part of Mastering Container Queries & Responsive Layouts.
Approach rationale: parse-time truth, evaluated by the engine
The alternatives to @supports all answer the question indirectly. User-agent sniffing asks "which browser is this?" and infers capability from a version table that is stale the day you ship it, wrong for embedded WebViews, and defeated by every browser that lies about its identity. A JavaScript probe asks the right question but answers it too late: the stylesheet has already been applied, so branching in script means either a flash as classes are swapped after paint, or a render-blocking inline script that costs you the time you were trying to save. Build-time branching cannot know anything about the visitor at all.
@supports asks the browser directly, in the same pass that parses the rest of your CSS, at zero runtime cost. The evaluation is purely syntactic: the engine takes the declaration inside the parentheses, runs it through its own property grammar, and reports whether it would keep or discard that declaration. Nothing is laid out, nothing is rendered, no heuristics are involved. That gives you a completely deterministic gate — the same browser always answers the same way — and it is why the test belongs in CSS rather than anywhere else.
The accessibility argument points the same direction. A layout that settles during parse is a layout that is stable by the time a screen reader builds its accessibility tree and by the time a keyboard user starts tabbing. A layout that is corrected by script after paint moves under people, and it is precisely users on slow hardware and assistive technology who feel that most.
The full grammar
@supports accepts four kinds of test, combined with three operators.
A declaration test is the common form: a property and value pair in parentheses. @supports (display: grid) is true if the engine considers display: grid a valid declaration. The parentheses are mandatory — @supports display: grid is a parse error and the whole block is dropped, which is a surprisingly common typo because it fails silently.
A selector test wraps a selector in the selector() function: @supports selector(:has(a)). This asks whether the engine can parse that selector, which is how you gate on :has(), ::backdrop, or any other selector-level feature. Note that it tests parsing only, exactly like the declaration form.
A font technology test comes in two flavours. font-format() asks about a container format — @supports font-format(woff2) — while font-tech() asks about a capability within a font, such as @supports font-tech(color-COLRv1) or @supports font-tech(variations). These are the right way to branch a @font-face strategy rather than guessing from version numbers.
An at-rule test, at-rule(@container), is specified but arrived late and unevenly; in mid-2026 it is not something to build a production gate on. Test the gateway property instead.
The operators are and, or, and not, and every operand must carry its own parentheses. @supports (display: grid) and (gap: 1rem) is valid; @supports (display: grid and gap: 1rem) is not. Mixing and with or at the same level requires explicit grouping — @supports ((a: b) or (c: d)) and (e: f) — because the grammar deliberately refuses to imply a precedence you might have got backwards.
Complete working implementation
The page below is a single runnable file that exercises every form of the grammar on one component: a declaration test that gates a container-query layout, a selector() test that gates a :has()-driven state, a negated test that supplies markup-specific fallback styling, and a compound test joined with and.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
/* ---------- Baseline. No @supports. Every engine ever gets this. ------- */
body { font-family: system-ui, sans-serif; margin: 2rem; color: #e8ecf4;
background: #0d1017; }
.panel {
display: grid;
grid-template-columns: 1fr; /* stacked: the honest default */
gap: 1rem;
padding: 1rem;
background: #11141c;
border-radius: 12px;
border-inline-start: 2px solid #7aa2ff;
}
.panel img { width: 100%; border-radius: 8px; }
/* ---------- 1. DECLARATION TEST ---------------------------------------
Gate the container-query layout on the gateway property. An engine that
cannot parse container-type also cannot evaluate @container, so it never
enters this block and keeps the stacked baseline above. */
@supports (container-type: inline-size) {
.panel-wrap { container: panel / inline-size; }
@container panel (min-width: 460px) {
.panel { grid-template-columns: 200px 1fr; align-items: start; }
}
}
/* ---------- 2. SELECTOR TEST ------------------------------------------
selector() asks whether the engine can PARSE the selector. Without it,
an engine that chokes on :has() would discard the whole rule anyway —
but wrapping it keeps the intent explicit and groups related rules. */
@supports selector(:has(img)) {
.panel:has(img) { padding-block-start: 0.5rem; }
}
/* ---------- 3. COMPOUND TEST with `and` -------------------------------
Each operand needs its OWN parentheses. Only gate on a compound when
the block genuinely needs both features; over-constraining denies the
enhancement to engines that could have had half of it. */
@supports (container-type: inline-size) and (color: color-mix(in srgb, red, blue)) {
.panel { background: color-mix(in srgb, #11141c 88%, #7aa2ff); }
}
/* ---------- 4. NEGATED TEST -------------------------------------------
`not` is for fallback styles the modern path must NEVER see. Here the
wrapper only becomes a positioning context where the ratio property is
missing; supporting engines leave it inert. */
@supports not (aspect-ratio: 16 / 9) {
.ratio { position: relative; padding-top: 56.25%; }
.ratio > * { position: absolute; inset: 0; width: 100%; height: 100%; }
}
@supports (aspect-ratio: 16 / 9) {
.panel img { aspect-ratio: 16 / 9; object-fit: cover; }
}
</style>
</head>
<body>
<div class="panel-wrap">
<section class="panel">
<div class="ratio">
<img src="https://example.com/thumb.jpg" alt="Trail marker at dusk">
</div>
<div>
<h2>Progressive panel</h2>
<p>Each enhancement is gated independently, so an engine that supports
one feature and not another still gets everything it can render.</p>
</div>
</section>
</div>
</body>
</html>
Key technique: @supports tests parsing, not behaviour
This is the single idea that determines whether your gates are correct. When a browser evaluates (display: grid), it does not consult a feature table, run a conformance check, or ask whether its grid implementation is complete. It parses the string display: grid against its own grammar for the display property and reports whether the declaration survives. That is the entire contract.
Everything else follows from it. A browser with a partial, buggy, or prefixed-only implementation of a feature can still parse the declaration and report true — which is exactly what happened during the multi-year window when early implementations of display: grid shipped without subgrid, or when gap worked in grid but not yet in flexbox. Parse-level acceptance and behavioural completeness are different properties, and @supports only ever tells you about the first.
This also explains the sharpest edge of the whole feature: testing a custom property is always true. @supports (--anything: whatever) passes universally, because custom properties are specified to accept nearly any token sequence, so the declaration is by definition valid. Gates written against design-token names therefore never gate anything; they just wrap the block in a rule that is always satisfied, and the bug is invisible until the unsupporting browser you were protecting renders the modern path and breaks. The same logic makes @supports (color: var(--brand)) useless — var() is valid in any property, so the test says nothing about the value it resolves to.
The practical rule: always test a real property with a real, feature-specific value, and pick the narrowest value that only a conforming implementation would accept. (container-type: inline-size) is a good probe because both halves are specific to the feature. (width: 100px) is a terrible probe because every engine since 1996 accepts it.
Variation: gating a selector and a font in the same sheet
Two forms that get less attention are worth wiring up together, because they gate very different kinds of enhancement. selector() lets you branch on a selector-level feature, and font-tech() lets a @font-face strategy adapt to what the engine can actually do with the file.
/* Selector-level gate: a focus-within enhancement that only makes sense
where :has() can hoist state from a descendant to an ancestor row. */
@supports selector(:has(:focus-visible)) {
.row:has(:focus-visible) {
outline: 2px solid #7aa2ff;
outline-offset: 2px;
}
}
/* Font technology gate: serve a colour font only where the engine can
render its COLRv1 layers, otherwise stick to the monochrome file. */
@font-face {
font-family: "Brand";
src: url("brand-mono.woff2") format("woff2");
}
@supports font-tech(color-COLRv1) {
@font-face {
font-family: "Brand";
src: url("brand-color.woff2") format("woff2") tech(color-COLRv1);
}
}
/* Negation with a fallback the modern path must not inherit. */
@supports not selector(:has(:focus-visible)) {
.row:focus-within {
outline: 2px solid #7aa2ff;
outline-offset: 2px;
}
}
The negated block here is doing real work rather than duplicating: :focus-within fires for any descendant focus including mouse clicks, which is a slightly worse experience than the :has(:focus-visible) version but a perfectly acceptable one — so the fallback is a genuine alternative, not a copy. That is the correct use of not: reach for it when the fallback needs declarations that would be actively wrong in a supporting engine. When your fallback is merely a plainer version of the same design, skip the negation entirely and let the positive block layer on top, which has the added benefit of also covering any ancient engine that does not understand @supports at all and therefore ignores both branches.
Browser support note
@supports and its declaration-test form are universally available and have been for over a decade: Chrome 28+, Edge 12+, Firefox 22+, Safari 9+. The selector() function landed considerably later, in Chrome and Edge 83+, Firefox 69+, and Safari 14.1+, so a selector() test returns false in engines older than that — which is usually the outcome you wanted anyway. font-tech() and font-format() are newer still, shipping in Chrome and Edge 108+, Firefox 106+, and Safari 17+; all three engines have them, but the Safari floor is high enough that they are best treated as an enhancement rather than a gate you depend on. Because an engine that does not understand a given test form simply evaluates it as false and drops the block, the mechanism degrades safely in every direction, which is what makes it the right foundation for the strategies in the guide to handling container query fallbacks for older browsers.
FAQ
Why test container-type instead of @container directly?
The classic @supports grammar tests declarations and selectors, not at-rules, and at-rule() testing is too new to depend on. Because container-type is the gateway property for size queries, any engine that parses it also implements @container, so the property test is a reliable proxy.
Does a passing @supports test mean the feature actually works?
No. @supports only reports that the browser can parse the declaration as valid. A browser can accept the syntax while implementing the behaviour partially or buggily, which is why parse-level support and behavioural correctness are different questions.
Why does @supports (--my-token: anything) always return true? Custom properties accept almost any token sequence by design, so the declaration is always valid and the test always passes. Never gate on a custom property name; test a real property and value instead.
When should I use @supports not instead of a plain test?
Use it when the fallback needs styles the modern path must never see, such as a padding-hack wrapper. If the fallback is just a plainer baseline, write it unguarded and let the positive @supports block layer over it, which also covers browsers that lack @supports entirely.
Related
- Container Query Fallbacks — the parent guide to graceful degradation strategies.
- Handling Container Query Fallbacks for Older Browsers — mapping container breakpoints onto viewport ones when the gate says no.
- aspect-ratio for Responsive Media — a negated test doing genuine work with the padding-hack fallback.
- Style Query Fallbacks and Support — what to do when the feature you want has no testable gateway property.
- Focus-visible vs Focus Polyfill Alternatives — cross-area: gating a selector-level feature on real focus behaviour.
Related articles
More pages in the same section.