Detecting CSS Support with the @supports Rule and JavaScript Feature Queries

Use @supports in CSS as the primary feature-detection mechanism, with CSS.supports() in JavaScript only for the gaps that the at-rule cannot cover, per the CSS Conditional Rules specification.

CSS feature detection is not what most people assume. The @supports at-rule tests whether a rendering engine can parse a declaration or match a selector. That is it. It will not tell you whether a feature is implemented correctly, whether interop bugs exist, or whether a value computes the way the spec intends. The honest version of CSS feature detection and JavaScript is a partnership: the at-rule handles what it can, the CSS.supports() method fills the gaps, and the cascade handles the rest. This guide walks the real gaps, the exact syntax for both tools, and the failure cases you will hit in production. If you write CSS daily, you need to know what shipped, what is safe to use, and what the safe default is, without reading five blog posts to find out. If you author a design system, you need precise specification behaviour and the vocabulary to defend choices. If you care about performance, you need to know what a declaration actually costs. If you write documentation, you need sourced statements, not guesses. If you are learning from zero, start at web.dev/learn/css or the MDN CSS first-steps guide, then return here. This is not a JavaScript site, and it is not a CSS-in-JS comparison. It is the detection layer, and it is thinner than you think.

The @supports Rule and the Cascade

The @supports at-rule syntax is straightforward: @supports <supports-condition> { <rule-list> }. Inside the condition you write either a declaration in parentheses, like (display: grid), or a function like selector(:has(a)) or font-tech(color-COLRv1). The rule list inside the block applies only when the condition evaluates to true. The safe default is the declaration outside the block. Engines that do not understand @supports at all ignore the entire at-rule, including any @supports not block, and apply the unguarded declaration. That is the oldest and most reliable fallback pattern, and it depends entirely on the cascade. The cascade orders origins, layers, specificity, and source order to determine which declaration wins. A declaration outside @supports behaves as if it has lower precedence than one inside, because the inside block comes later in source order when you write the safe default first. This is not a hack; it is the designed degradation path.

How the Cascade Resolves the Safe Default

Consider a rule for a component card. Write the safe default first: .card { display: block; }. Then write the progressive enhancement: @supports (display: grid) { .card { display: grid; } }. An engine that understands @supports and grid applies the grid. An engine that understands @supports but not grid skips the block and keeps block. An engine that does not understand @supports at all ignores the at-rule, but the block layout still applies because the safe default is outside it. This is the pattern that makes @supports safe to use everywhere, even in older engines. The same logic applies to @supports selector(). The accepted safe default for @supports selector() is a duplicate rule outside the block with the same selector. The accepted safe default for @supports font-tech() and font-format() is the same duplicate-rule pattern. The cascade is not a workaround; it is the feature that makes feature detection a styling decision rather than a build-time decision.

Detecting Support with @supports selector() and the :has() Safe Default

@supports selector() checks whether the engine can parse and match a complex selector, not whether a declaration is valid. This closes a real gap: an engine might support the :has() pseudo-class in its parser but break on a specific argument, or support it only for certain combinators. The selector() function tests the whole thing. The syntax is @supports selector(<complex-selector>) { <rule-list> }. The complex-selector is any selector the specification allows, including :has() with a relative selector list. The result is boolean: true if the engine can parse and match it, false otherwise.

Runnable Sample: The :has() Test with a JavaScript Class Safe Default

Here is a complete sample that applies a grid layout when the engine supports :has(), and adds a class via CSS.supports() when it does not. The class lets JavaScript apply an alternative layout without duplicating the whole stylesheet logic.

<!DOCTYPE html>
<html lang="en">
<head>
<style>
  .cards { display: block; }
  @supports selector(:has(> .card)) {
    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
    .card:has(> .badge) { outline: 2px solid #c00; }
  }
</style>
</head>
<body>
  <div class="cards" id="cards">
    <div class="card"><span class="badge">Hot</span>Card content</div>
    <div class="card">Card content</div>
  </div>
<script>
  if (!CSS.supports('selector(:has(> .card))')) {
    document.getElementById('cards').classList.add('no-has');
  }
</script>
</body>
</html>

If the engine lacks :has(), the JavaScript adds the no-has class. In that class you position the badge differently, or change the outline. The key is that the JavaScript does not re-implement layout; it only switches a hook that existing CSS already targets. The @supports selector() block handles the modern case; the class handles the gap. This keeps the detection logic in the stylesheet where it belongs, and the script only adds a marker.

What @supports Cannot Detect: The Named Gaps

The CSS Conditional Rules Module Level 3 specification is explicit about what @supports can test: a declaration or a selector. It cannot test for support of an entire at-rule, it cannot test for a specific feature within an at-rule, and it cannot test for individual values of a shorthand that the engine handles as a single token. The specification names the grammar: <supports-feature> is either a <supports-decl> (a parenthesised declaration) or a <supports-condition> that combines them with not, and, or or operators. The general-enclosed production allows any parenthesised expression, but that does not mean the engine evaluates it; it means the engine accepts the syntax and ignores the result if it does not understand it. That is a gap, not a feature.

The Specific Gaps You Will Hit

The first gap: @supports cannot test for @layer support. The @layer statement is an at-rule, not a declaration, and @supports only evaluates declarations. You cannot write @supports (@layer) because that is invalid syntax. To test @layer support, you must use CSS.supports() with a condition string, but even that fails because the specification does not allow an at-rule inside <supports-condition>. The practical workaround is to check for a declaration that only works inside a layer, which is not reliable. The second gap: @supports cannot test for specific container query features. You can test @supports (container-type: inline-size), but you cannot test whether @container style queries work, because style queries are an at-rule, not a declaration. You also cannot test for container query units like cqw or cqh with @supports; those are units, not declarations. The third gap: @supports cannot test for individual font-variation-settings axes. The font-tech() function can test for a technology like variations, and font-format() can test for a file format like woff2, but neither can test whether a font file contains a specific axis like ‘wght’ or ‘wdth’. That requires manual testing with JavaScript and a font loading API. The Baseline status dashboard tracks these gaps: @supports selector() is widely available since 2023-09-18, @supports font-tech() has limited availability as of 2026-09-16, and @supports font-format() has limited availability as of 2026-09-16, per MDN. The figures are published by the Web Platform Status dashboard. These gaps are not theoretical; they are the reason the JavaScript safe default exists.

CSS.supports() and the Static Method Gap-Filler

CSS.supports() is the JavaScript mirror of @supports. The static method takes either two arguments, a property and a value, or one argument, a condition string. The two-argument form checks a single declaration: CSS.supports('display', 'grid') returns true or false. The one-argument form takes a condition string exactly as you would write inside @supports: CSS.supports('(display: grid) and (selector(:has(*)))'). The return type is boolean. The method is widely available since 2015-09-30, per MDN, and works in every modern engine. It does not replace @supports; it complements it. The reason you need it is that @supports cannot run inside a script, and CSS.supports() cannot style anything. Together they let you make a runtime decision and apply it.

Runnable Sample: Detecting Custom Property Registration

One feature @supports cannot detect is whether a custom property has been registered with a syntax other than the guaranteed-invalid default. Registration happens via CSS.registerProperty() or the @property at-rule. Neither is a declaration, so @supports cannot test it. CSS.supports() can test for the registration by asking whether a specific syntax is valid, but the method does not expose registration state. The practical test is to register a property and then check its computed value via getComputedStyle. Here is a complete sample:

<!DOCTYPE html>
<html lang="en">
<head>
<style>
  .theme { --accent: blue; }
</style>
</head>
<body>
  <div class="theme" id="theme">Test accent color</div>
<script>
  // Attempt registration; some engines throw if already registered.
  try {
    CSS.registerProperty({
      name: '--accent',
      syntax: '<color>',
      inherits: true,
      initialValue: 'black'
    });
  } catch (e) {
    // Already registered or unsupported; proceed.
  }
  const el = document.getElementById('theme');
  const value = getComputedStyle(el).getPropertyValue('--accent').trim();
  const registered = value !== '' && value !== 'black';
  console.log('Custom property registration detected:', registered);
</script>
</body>
</html>

The check is not perfect, because the initial value is black and the stylesheet sets blue, so the computed value reflects the stylesheet, not the registration. A better test is to read the property descriptor, which is not yet standard. The point is that @supports cannot do this at all, and CSS.supports() only helps with the syntax check. The real gap is that registration support must be inferred from behaviour, not queried. This is the kind of gap that forces manual testing, and the Baseline dashboard tracks it per feature per engine.

Progressive Enhancement Without JavaScript: The Cascading Safe Default

The most important pattern is the one that needs no JavaScript at all. Progressive enhancement is not about feature detection; it is about writing a valid default and then layering enhancement on top. The cascade does the work. When you use @supports not, you are explicitly saying: if this feature is absent, apply this other rule. The @supports not operator takes a <supports-condition> and inverts it. The syntax is @supports not (display: grid) { .card { display: block; } }. This is a real safe default, not a guess. But the common mistake is using @supports not (display: grid) as a safe default for engines that do not support @supports itself. Those engines ignore the entire at-rule, including the not block, and apply neither branch. The safe pattern is to always write the safe default outside the @supports block, and the enhancement inside. The @supports not block is only useful when you need to override a default that is already applied.

Runnable Sample: Degrading Gracefully Without JavaScript

Here is a complete sample that uses @supports not to provide a degraded layout when container queries are unavailable, without any script:

<!DOCTYPE html>
<html lang="en">
<head>
<style>
  .container { display: block; }
  .item { padding: 1rem; border: 1px solid #ccc; }
  /* Safe default: stack items vertically */
  .item { margin-bottom: 1rem; }
  /* Progressive enhancement: use container queries for spacing */
  @supports (container-type: inline-size) {
    .container { container-type: inline-size; }
    .item { margin-bottom: 0; }
    @container (min-width: 400px) {
      .item { display: flex; align-items: center; }
    }
  }
  /* Explicit degradation when container queries are absent */
  @supports not (container-type: inline-size) {
    .item { border-left: 4px solid #999; }
  }
</style>
</head>
<body>
  <div class="container">
    <div class="item">Item one</div>
    <div class="item">Item two</div>
  </div>
</body>
</html>

In this sample, the safe default margins apply first. If the engine supports container queries, the @supports block overrides the margin and applies the container behaviour. The @supports not block adds a visual indicator when the feature is missing. There is no JavaScript, no polyfill, and no runtime detection. The cost is a few extra bytes of CSS. The failure case is the engine that does not support @supports at all; it applies the unguarded margin, ignores the @supports not block, and the user sees a stacked layout with a left border only if the engine also supports the declaration inside the not block. That is the honest limitation: you cannot test for the absence of @supports itself with @supports.

Detection Gaps and the Interop Dashboard

The gaps in @supports coverage are not accidental; they are the result of the specification’s grammar. The CSS Conditional Rules Module Level 3 specification defines <supports-feature> as either a declaration or a selector, and the font-tech() and font-format() functions are recent additions. The specification does not define a way to test for an at-rule, a unit, or a specific value inside a shorthand. The Interop dashboard tracks per-feature conformance across engines, and the figures show that new features like container style queries and @property have different support timelines. The practical consequence is that you cannot write a single @supports query that covers everything. You must decide which feature is the one that matters, test for it, and accept that the engine is the final renderer.

What the Dashboard Tells You, and What It Does Not

The Interop dashboard scores engines on passing shared test suites. A feature can be marked as shipped in all three engines but still fail the dashboard’s conformance tests. For example, container query units (cqw, cqh) shipped in Safari 16, but older versions of Safari had interop bugs where the units resolved incorrectly. @supports (container-type: inline-size) returns true in those engines, yet the units compute wrong. No feature query can catch that. The same issue applies to :has(): the selector() function tests parse-and-match, but a complex :has() with a compound selector can be slower in one engine than another. The dashboard gives you a score, not a promise. The Baseline status dashboard gives you a categorical label: widely available, newly available, or limited. The label changes on a rolling 30-month window, so the 2024 status is not the 2026 status. When you rely on a feature, check the dashboard for the specific engine version you target, not the broad label.

Common Mistakes and Cost Considerations

The most common mistake in feature detection is testing a shorthand property. For example, @supports (display: grid) returns true in an engine that supports the shorthand but not a specific longhand value like display: inline-grid or a grid-template-columns value that is not yet implemented. The shorthand is a single token; the engine either parses it or not. The longhand value is separate. Fix this by testing the exact value you intend to use: @supports (grid-template-columns: subgrid) instead of (display: grid). The second mistake is using @supports not as a safe default for engines that lack @supports itself, as described earlier. The third mistake is assuming that a feature query is a performance guarantee. A single broad :has() can be more expensive than a long compound selector, because the engine must evaluate the relative selector against every element. The cost is not in the detection; it is in the match. The @supports block only runs once at parse time, but the selector inside it runs for every element it matches. Measure with the engine’s profiler, not a guess.

What Each Feature Actually Costs

Custom properties are cheap to define but can be expensive to compute when they are used in calc() or in container style queries, because the engine must resolve the dependency graph at computed value time. Container queries are more expensive than media queries because they require layout information to be available before the query can be evaluated. The cascade itself is free at runtime, but a long rule list increases parse time. The honest answer is that feature detection adds a few bytes to the stylesheet and a few microseconds to the parse, and the real cost is in the feature you are enabling. The failure mode for custom properties is the property not updating, usually caused by the property being set on a parent that does not match the expected inheritance chain, or by a typo in the var() fallback syntax that silently fails. The failure mode for transitions is the property value not changing in a way that produces computed-value interpolation; auto to 0 does not transition, display does not transition, and the initial value must be set before the target value. The failure mode for container queries is that the query has no container to measure, which happens when the container-type property is not set on an ancestor. The failure mode for :has() is the selector inside being invalid in the context, or the engine not supporting :has() at all. The failure mode for text-wrap: balance is applying it to single-line text, which does nothing. The failure mode for font-variation-settings is the font file not containing the requested axis, or using font-variation-settings without a fallback font-weight that matches; variation settings do not cascade with the individual properties.

The @supports selector() and font-tech()/font-format() Functions

@supports selector() is a standard function that tests a single selector. It is widely available since 2023-09-18, per MDN. The syntax is @supports selector(:has(a)) or @supports selector(.foo > .bar). You can combine it with the not, and, and or operators: @supports selector(:has(a)) and (display: grid). This is the correct way to test for a feature that requires both a selector and a property. The font-tech() and font-format() functions are newer. @supports font-tech(color-COLRv1) tests whether the engine supports a font technology, and @supports font-format(woff2) tests whether it can parse a file format. Both have limited availability as of 2026-09-16, per MDN. The practical use is for custom icon fonts and variable font safe defaults. The failure mode is that an engine might support the format but not the specific technology, so you need both tests. The cost is minimal, but the interop is not universal. The status changes, so check the dashboard before shipping a font stack that depends on these functions.

Style Queries, Container Queries, and What the Query Can and Cannot Test

Style queries are container queries that respond to the computed value of a custom property on the container, not to a size measurement. They are a distinct feature from size container queries. @supports (container-type: inline-size) tests size queries, but it does not test style queries. Style queries use the @container at-rule with a style() condition, like @container style(--theme: dark). The @supports at-rule cannot test for style queries because the condition inside is an at-rule, not a declaration. The only way to test for style queries is to use CSS.supports() with a condition string that includes the @container syntax, but that is not valid in the method either, because the method only accepts declarations or selectors. The real gap is that you cannot feature-detect style queries at all, which forces a JavaScript safe default that checks whether a custom property changes a computed style. The distinguishing feature between style queries and size queries is the type of condition: a value versus a measurement. The performance cost of style queries is higher than size queries because the engine must recompute the custom property value to evaluate the condition. The failure mode is a container that has no style query condition matching the current custom property, which means no rule applies.

The Constraint-Solving System

Custom properties plus calc() plus @container style queries form a constraint-solving system that behaves like a limited logic language. A style query can test equality to a specific value, but not greater-than or less-than. The system is not Turing-complete, but it is expressive enough to implement component-variant logic without JavaScript. The danger is treating it as dumb. Over-engineered JavaScript solutions often re-implement what the cascade already does. The correct approach is to define the custom property on the container, then write style queries for each variant. The JavaScript only needs to set the custom property on user action; the CSS handles the rest. This is the progressive enhancement pattern done right: the CSS does the work, the JavaScript only flips a switch. The failure mode is that the custom property does not inherit as expected, so the style query never matches. This is the computed value stability issue: custom properties can change at runtime, and the style query runs at computed value time, so a change in the custom property triggers a re-evaluation. The cost is that a style query on a large subtree can be expensive, because the engine must re-compute the custom property value for every element in the subtree.

The Most Honest Version: The Browser Is the Final Renderer

The honest version of the entire feature detection story is that you describe what should happen under which conditions and accept that the rendering engine is the final renderer. No feature query, no CSS.supports() call, and no dashboard label can guarantee that a feature behaves correctly in every engine version. The Interop dashboard tracks this per feature per engine, but the score is a snapshot, not a promise. The practical advice is to test in the engines your users actually use, not in the latest dev channel. The failure mode is trusting the feature query and then finding that an engine reports true but computes the value wrong. The mitigation is to use the cascade to provide a sensible default and treat the enhancement as a bonus, not a requirement. This is the progressive enhancement CSS pattern: the page is usable without the feature, and better with it. The JavaScript role is not to detect support; it is to add behaviour that CSS cannot express, like responding to JavaScript state by flipping custom properties. The transition from one state to another still requires JavaScript for the trigger, but the animation itself is CSS. The gap is the JS dependency that is often omitted from CSS-focused documentation. The honest sentence is this: feature detection is a boolean, but rendering is a continuum. The boolean tells you whether to try; the engine tells you whether it worked.

Frequently Asked Questions

Can @supports test for @layer support?

No. @supports only tests declarations or selectors, and @layer is an at-rule. CSS.supports() cannot test it either. The workaround is to check whether a declaration that only works inside a layer is applied, but that is unreliable. The specification does not allow at-rules inside a supports-condition.

Why does @supports not (display: grid) fail in old engines?

Engines that do not support @supports ignore the entire at-rule, including the not block. The safe default declaration outside the block applies, but the @supports not block itself is not evaluated. Always write the safe default outside any @supports block.

How do I test for font variation axes?

You cannot use @supports or CSS.supports() for individual axes. Use the Font Loading API to request a specific variation and check whether it loads. The axes are defined in the font file, not in the CSS parser, so feature detection cannot see them.

What is the difference between Baseline status and Interop scores?

Baseline status is a categorical label: widely available, newly available, or limited, based on a rolling 30-month window. Interop scores are pass rates on a shared test suite per engine. A feature can be widely available but still have interop bugs. Check both before shipping.

The Web Platform Status Dashboard and What Changes

The figures for @supports font-tech() and @supports font-format() have limited availability as of 2026-09-16, per MDN. The Baseline status dashboard publishes the categories, and the Interop dashboard publishes the test scores. These are different sources. The Web Platform Status dashboard is the aggregator for both. The note: the exact dates for font-tech() and font-format() may have shifted since the fact sheet was prepared; the publisher is MDN, and you should check the MDN page for the feature to see the current date. The practical advice is to use font-tech() and font-format() only in a progressive enhancement that fails safely. The failure mode is that an engine might support the syntax but not the technology, and the @supports block applies the rule incorrectly. The mitigation is to use the function only to add a font, never to remove one. The cascade handles the rest.

Do Not Use @supports not for Engines That Lack @supports

Do not use @supports not to handle engines that lack @supports itself, because the entire at-rule is ignored, including the safe default branch, which is a specification-level trap that most tutorials gloss over. The failure is precise: the engine ignores the whole at-rule, so neither branch applies. This is the kind of concrete detail that distinguishes a useful page from a generic one.