Checking Layer Support With CSS.supports in JavaScript

The natural first attempt — CSS.supports('@layer') — returns false in Chrome 130, Firefox 140 and Safari 18 alike, which sends people to the wrong conclusion. The API is not broken; it answers a different question. This page covers what each detection mechanism actually tests and when any of them is worth shipping, extending testing cascade layers across browsers within the browser support, compatibility and migration reference.

Prerequisites

You need to know that @layer reached Baseline in March 2022 — Chrome 99, Firefox 97, Safari 15.4 — and that an unsupported at-rule causes the browser to discard the rule and its entire block.

Step 1: Understand why CSS.supports cannot answer this

// All of these return false in fully supporting browsers.
CSS.supports('@layer');                 // false — not a declaration
CSS.supports('@layer', 'base');         // false — not a property/value pair
CSS.supports('at-rule(@layer)');        // false — not the JS form of the syntax

// WHY: CSS.supports takes either a "property: value" declaration or, in its
// one-argument form, a condition made of such declarations and selector()
// tests. At-rules are outside its grammar entirely, so the answer is always
// false — which is indistinguishable from "not supported" and therefore a
// trap.
CSS.supports('color', 'revert-layer');  // true in supporting browsers ✓

What this does: rules out the obvious approach and provides a usable substitute. The last line is the practical trick — revert-layer is a value, so CSS.supports can test it, and it shipped in exactly the same releases as @layer. Testing it is a reliable proxy.

Four detection approaches and what each really tests Four rows. CSS.supports with an at-rule string always returns false and is marked incorrect. CSS.supports testing the revert-layer value works as a proxy. The CSSOM probe directly checks whether a layer rule was retained. The at-rule() supports gate works in CSS with no JavaScript. approach what it actually tests valid? CSS.supports('@layer') the intuitive first attempt nothing — at-rules are outside its grammar CSS.supports('color','revert-layer') a value that shipped alongside @layer a proxy — same release wave, so it correlates CSSOM probe sheet insert @layer, read it back the real thing — was the rule retained? @supports at-rule(@layer) CSS only, no JavaScript at-rule support, in the language itself — prefer this

Step 2: Probe the CSSOM for a definitive answer

// WHY a constructed stylesheet: it never touches the document, so the probe
// cannot affect rendering. If the engine does not understand @layer it
// discards the rule at parse time, and cssRules comes back empty.
export function supportsCascadeLayers() {
  try {
    const sheet = new CSSStyleSheet();
    sheet.replaceSync('@layer probe { }');
    return sheet.cssRules.length > 0 &&
           sheet.cssRules[0].constructor.name === 'CSSLayerBlockRule';
  } catch {
    // Constructable stylesheets are themselves a 2021+ feature; a browser
    // without them certainly predates @layer, so false is the right answer.
    return false;
  }
}

What this does: tests the actual at-rule rather than a correlate. Use it when you need certainty — for example when deciding whether to fetch a polyfilled bundle, where a wrong answer costs a download.

Step 3: Prefer the CSS-only gate where the fallback is styling

/* WHY at-rule() rather than JavaScript: no script, no layout shift, no
   hydration timing. The browser evaluates this while parsing. */
@supports at-rule(@layer) {
  @layer components {
    .card { border: 1px solid var(--color-border); }
  }
}

/* WHY the negated branch is a separate, self-contained block: an engine that
   does not understand @layer also discards the @layer block above ENTIRELY,
   so the fallback must restate the rules rather than adjust them. */
@supports not at-rule(@layer) {
  .card { border: 1px solid #d4c89a; }
}

What this does: keeps detection in the language that owns the decision. Note that at-rule() inside @supports is itself newer than @layer, so an engine that supports neither takes the fallback branch — which is the correct outcome by accident, and worth confirming in your oldest target browser.

Step 4: Decide what the fallback actually does

// WHY a decision, not a reflex: each option has a real cost, and for most
// audiences the right answer is the last one.
if (!supportsCascadeLayers()) {
  // Option A — fetch the polyfilled bundle. Costs a round trip for old
  // browsers only, and the polyfill inflates specificity for everyone in it.
  import('/assets/index.polyfilled.css');

  // Option B — degrade. The layered stylesheet was discarded, so the page
  // needs a complete unlayered fallback, not a patch.

  // Option C — do nothing. Correct when the affected traffic is negligible,
  // and by far the cheapest to maintain.
}

What this does: forces the choice to be explicit. Option B is the one most often underestimated: because an unsupported @layer block is discarded wholesale, the fallback is an entire parallel stylesheet, not a few overrides.

Is runtime layer detection worth shipping? A decision tree. If no measurable traffic predates March 2022, ship no detection. Otherwise, if the fallback only changes styling, use the CSS at-rule gate. If it changes behaviour, use the CSSOM probe in JavaScript. Does measurable traffic predate March 2022? no yes ship no detection at all the cheapest correct answer Is the fallback styling or behaviour? styling behaviour @supports at-rule(@layer) no JavaScript needed CSSOM probe sheet definitive, costs a script

Verification

  1. Run the probe in a current browser — it must return true, and CSS.supports('@layer') must return false. Seeing both confirms you are testing what you think you are.
  2. Verify the CSS gate takes the right branch by temporarily inverting it to @supports not at-rule(@layer) and confirming the fallback renders.
  3. Check the fallback stylesheet is genuinely complete:
Three detection results and what each means Three outcomes of the detection probe: both the CSSOM probe and the value proxy returning true means full support, the proxy alone is inconclusive, and both false means a pre-2022 engine. probe true, proxy true full support — no fallback needed probe false, proxy true inconclusive — trust the CSSOM probe both false a pre-2022 engine, or no constructable stylesheets
# Every selector in the layered bundle should have a counterpart in the
# fallback — an unsupported @layer block is discarded whole, not partially.
grep -o "^\s*\.[a-z0-9_-]*" dist/assets/index.css | sort -u > /tmp/layered.txt
grep -o "^\s*\.[a-z0-9_-]*" dist/assets/fallback.css | sort -u > /tmp/fallback.txt
comm -23 /tmp/layered.txt /tmp/fallback.txt
  1. Confirm the detection is still needed. If your analytics show no pre-2022 engines, delete it.

Troubleshooting

The probe returns false in a modern browser. : constructor.name is unreliable under aggressive minification that renames globals. Compare with CSSLayerBlockRule directly via instanceof, guarded by a typeof CSSLayerBlockRule !== 'undefined' check.

@supports at-rule(@layer) never matches. : The at-rule() function is newer than @layer itself, so an engine may support layers but not the test. Where that matters, use the revert-layer value proxy in CSS: @supports (color: revert-layer) { … }.

The fallback stylesheet is missing most styles. : An unsupported @layer block is discarded in full, including every rule inside it. The fallback has to be a complete parallel stylesheet, which is why generating it with the PostCSS polyfill is usually cheaper than maintaining it by hand.

Detection runs before the stylesheet loads. : The CSSOM probe does not depend on your stylesheets — it constructs its own — so ordering does not matter for it. If you are instead inspecting document.styleSheets, that is load-order dependent and will report nothing when run too early.

A bot or preview renderer takes the fallback branch. : Older headless renderers and some link-preview services predate 2022. Check whether that traffic matters before treating it as a defect; for most sites it does not.

Complete working example

// src/lib/layer-support.js — complete, dependency-free detection.

/**
 * Definitive test for @layer support.
 * WHY a constructed sheet: it is never adopted into the document, so the
 * probe has no rendering side effects and no cleanup.
 */
export function supportsCascadeLayers() {
  if (typeof CSSStyleSheet === 'undefined') return false;
  try {
    const sheet = new CSSStyleSheet();
    sheet.replaceSync('@layer probe { }');
    if (sheet.cssRules.length === 0) return false;
    // WHY instanceof with a guard: constructor.name breaks under minifiers
    // that rename globals; the guard covers engines lacking the interface.
    return typeof CSSLayerBlockRule !== 'undefined' &&
           sheet.cssRules[0] instanceof CSSLayerBlockRule;
  } catch {
    return false;
  }
}

/**
 * Cheap proxy for the same question, usable where a constructed sheet is
 * unavailable. WHY it works: `revert-layer` shipped in the same releases as
 * @layer, and unlike an at-rule it IS testable by CSS.supports.
 */
export function supportsCascadeLayersProxy() {
  return typeof CSS !== 'undefined' &&
         CSS.supports?.('color', 'revert-layer') === true;
}

/**
 * The only decision most applications need to make.
 * WHY it is async and lazy: the fallback bundle must not cost anything for
 * the overwhelming majority of visitors who do not need it.
 */
export async function ensureStyles() {
  if (supportsCascadeLayers()) return 'native';
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = '/assets/index.fallback.css';
  document.head.append(link);
  return 'fallback';
}
/* src/styles/entry.css — the CSS-only equivalent, preferred where the
   difference is purely visual. */
@supports at-rule(@layer) {
  @layer reset, base, theme, components, utilities;
  @import url("./layers/all.css");
}

/* WHY a full restatement rather than a patch: without @layer support the
   block above is discarded in its entirety, so this branch is not an
   override — it is the whole stylesheet for those engines. */
@supports not at-rule(@layer) {
  @import url("./fallback/all.css");
}

Frequently Asked Questions

Why does CSS.supports('@layer') not work?

Because CSS.supports evaluates declarations and selectors, and an at-rule is neither. Its one-argument form parses a supports condition made of property: value pairs and selector() tests; an at-rule name matches nothing in that grammar, so the call returns false in every engine — including ones with complete layer support. The result is indistinguishable from a genuine negative, which is what makes it a trap rather than merely a limitation.

Is runtime detection of @layer worth shipping?

For most audiences, no. Layers reached Baseline in March 2022, so the detection code, the fallback stylesheet and the branch they create usually cost more to maintain than the traffic they serve. It earns its place in two situations: a measurable share of visitors on pre-2022 engines, or a component that must choose between two behaviours rather than two stylings, where CSS alone cannot express the decision.

What happens in a browser that does not support @layer?

The engine does not recognise the at-rule, so it discards the rule together with everything inside its block. That is a complete loss of the layered styles rather than a graceful degradation — the page renders with whatever unlayered CSS remains, which for a fully migrated codebase is nothing at all. It is the reason a fallback must be a parallel stylesheet, and the reason generating it with the PostCSS polyfill beats maintaining it by hand.

Up: Testing Layers Across BrowsersBrowser Support, Compatibility & Migration