Browser Support, Compatibility & Migration for @layer

Teams stall on @layer for two reasons, and neither is the syntax. The first is a support question that never gets a straight answer — “can we actually ship this, or will it break in the Safari our enterprise customers are pinned to?” The second is a comparison question — “how is this different from the CSS Modules, Shadow DOM, or BEM we already rely on, and do we throw those away?” This section answers both. Cascade layers have been Baseline Widely Available since mid-2022, the polyfill story is well-understood, and layers coexist with every scoping strategy you already use rather than replacing them. What follows is the decision framework, the feature-detection and progressive-enhancement patterns, the build tooling, and the migration comparisons you need to make the call with confidence.

What “Baseline Widely Available” means for @layer

Baseline is the interoperability signal published across MDN, caniuse, and web.dev. A feature is Baseline Newly Available the day it lands in the last of the major engines, and it graduates to Baseline Widely Available 30 months later — the point at which you can assume it works for essentially the entire audience without a fallback. @layer crossed the finish line in Safari 15.4 on 14 March 2022, having already shipped in Chrome/Edge 99 (1 March 2022) and Firefox 97 (8 February 2022). That means it has been Widely Available since roughly September 2024, and every browser version released in the past three-plus years understands it natively.

The minimal contract you are relying on is this declaration, which every page in this reference uses as its running example:

/* One line, understood natively by every 2022+ engine.
   Declares six named layers in ascending precedence order:
   later-named layers win over earlier ones, regardless of specificity. */
@layer reset, base, themes, components, utilities, overrides;

For a spec-level walk-through of what that line does to the cascade, see the CSS cascade fundamentals and layer syntax section. The practical takeaway for a support decision: unknown at-rules are ignored by browsers that do not understand them, so on a pre-2022 engine the @layer declaration line above is silently dropped — but so is the precedence it was supposed to establish. That failure mode is what the rest of this section is about.

Core mechanism: how compatibility is actually achieved

There are three distinct techniques for shipping @layer to a mixed audience, and confusing them is the source of most migration anxiety. A polyfill rewrites your CSS so old browsers get the right result. Feature detection branches your CSS so each browser gets code it understands. Graceful degradation accepts a lesser-but-functional result on old browsers. You will usually pick one; occasionally you combine them.

The diagram below is the decision the rest of this page formalises: check support, then ship native, polyfill, or progressively enhance.

@layer adoption decision flow A decision flow. Start by asking whether the browser matrix supports @layer. If support is universal, ship native @layer with no tooling. If a small share of old browsers remains, either run the PostCSS polyfill to rewrite layer order into specificity, or progressively enhance with an @supports feature query and a flat fallback stylesheet. Does your matrix support @layer? (check caniuse) split by traffic share Universal (2022+ only) no tooling needed Small legacy tail still critical to you Old but non-critical degrade acceptably Ship native @layer zero build cost PostCSS polyfill order to specificity Progressive enhance @supports + fallback

How the polyfill converts layer order into specificity

Before @layer existed, the only lever an author had for “this rule must beat that rule” was specificity. The polyfill exploits exactly that fact: it reads your declared layer order at build time and re-encodes each layer’s precedence as selector weight, then deletes the @layer wrappers so the output is plain, universally-understood CSS.

Concretely, @csstools/postcss-cascade-layers walks your declaration order, assigns each layer a rank, and prefixes selectors in higher-ranked layers with repeated :not(#\#) pseudo-classes. #\# is an escaped ID selector that can never match a real element, and :not() takes the specificity of its argument — so :not(#\#) adds one ID’s worth of specificity while matching everything. Stack N of them and you have raised a selector’s weight by N IDs without changing what it selects:

/* You author this (native @layer) ... */
@layer base, components;
@layer base       { a { color: blue; } }
@layer components { a { color: red;  } }

/* ... the polyfill emits roughly this (no @layer, plain specificity).
   The 'components' layer ranks higher, so its selector gets extra
   :not(#\#) prefixes to out-weigh the identical selector in 'base'. */
a:not(#\#):not(#\#) { color: red;  }   /* components: +2 ID specificity */
a:not(#\#)          { color: blue; }   /* base: +1 ID specificity        */

Both selectors still match every <a>, but red now wins by raw specificity on any engine, exactly as native layer order would have decided. The mechanism is elegant and it is also its own warning label: it inflates specificity across your whole stylesheet, so hand-written high-specificity selectors can collide with the polyfill’s synthetic weight in ways that are hard to debug. This is precisely why understanding calculating selector weight in layers matters when you polyfill — the polyfill is making selectors heavier on purpose, and you need to reason about the result.

How @supports at-rule(@layer) feature detection works

The CSS @supports rule normally tests property/value pairs, but it also has an at-rule() function that tests whether an at-rule is recognised. @supports at-rule(@layer) { … } therefore applies its block only where cascade layers are understood, giving you a native branch point with no JavaScript:

/* Applies ONLY where @layer is understood natively.
   Old engines skip this whole block because they don't recognise
   the at-rule() query itself — which is exactly the branch we want. */
@supports at-rule(@layer) {
  @layer reset, base, themes, components, utilities, overrides;

  @layer components {
    /* :where() keeps specificity at 0-0-0 so layer order is the
       only thing deciding precedence — the modern, clean path. */
    :where(.btn) { padding: 0.5rem 1.25rem; border-radius: 6px; }
  }
}

/* The complementary branch for engines WITHOUT @layer.
   Flat, source-ordered CSS with hand-tuned specificity. */
@supports not (at-rule(@layer)) {
  .btn { padding: 0.5rem 1.25rem; border-radius: 6px; }
}

The honest caveat: at-rule() support inside @supports is newer than @layer itself, so a truly ancient browser may understand neither query and fall through both blocks. Treat at-rule() detection as a clean enhancement for the “modern vs. very modern” split, not as a bulletproof gate for 2015-era engines — for those, the polyfill or a separately-linked fallback stylesheet is safer.

Graceful degradation: what “acceptable” looks like without layers

Degradation is the option where you ship native @layer and accept that unsupporting browsers get a different but usable result. Because unknown at-rules are ignored, a pre-2022 browser drops your @layer blocks — including their rules if they are inside the block. The design goal for graceful degradation is therefore to ensure the ignored-layer outcome is still a coherent page: readable typography, no broken layout, perhaps slightly wrong override precedence. This is appropriate when your legacy tail is real but non-critical (an internal tool’s occasional old-Safari visitor) and when your visual identity survives a few mis-ordered overrides. It is not appropriate when a mis-ordered override means an unreadable checkout button. The normalization and reset in layers guide is worth reading here, because a robust reset is what keeps a de-layered page from collapsing into unstyled defaults.

Implementation patterns

Pattern 1: PostCSS polyfill setup

The lowest-friction way to support pre-2022 engines is to author native @layer everywhere and let the build rewrite it. Add the polyfill to PostCSS and drive its target browsers from a single Browserslist query so the transform turns itself off automatically once your matrix moves on:

// postcss.config.js
module.exports = {
  plugins: [
    // Rewrites @layer order into specificity chains for any browser in
    // your Browserslist that lacks native support. When Browserslist
    // resolves to only 2022+ engines, this plugin becomes a no-op —
    // so you can leave it in the pipeline and delete it later, safely.
    require("@csstools/postcss-cascade-layers")({
      // 'warn' surfaces selectors the polyfill can't safely rewrite
      // (e.g. pre-existing #id selectors that clash with the synthetic
      // :not(#\#) weight) so you catch them in CI instead of production.
      onRevertLayerKeyword: "warn",
      onConditionalRulesChangingLayerOrder: "warn",
    }),
  ],
};
# .browserslistrc — the single source of truth for "who do we support?".
# Both the polyfill and autoprefixer read this. Tighten it and the
# polyfill quietly stops emitting fallback specificity.
defaults
Safari >= 15.4
Chrome >= 99
Firefox >= 97
not dead

Pattern 2: Progressive enhancement with a feature query

When you would rather not inflate specificity across the whole build, branch the CSS instead. Modern browsers take the layered path; everything else takes a flat, source-ordered path whose precedence you control by hand. This keeps native layers pristine for the browsers that support them:

/* Modern path: real layers, clean specificity. */
@supports at-rule(@layer) {
  @layer reset, base, themes, components, utilities, overrides;

  @layer utilities {
    /* Utilities win over components purely by layer order — no !important,
       no specificity escalation. This is the payoff of native layers. */
    .u-hidden { display: none; }
  }
  @layer components {
    :where(.card) { padding: 1rem; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); }
  }
}

/* Fallback path: no layers available. Recreate the intended precedence
   with source order + minimal specificity. Utilities come LAST so they
   still win, mirroring the layer order above. */
@supports not (at-rule(@layer)) {
  .card { padding: 1rem; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); }
  .u-hidden { display: none !important; } /* the one place !important earns its keep */
}

Pattern 3: Fallback ordering for a de-layered page

If you ship a single stylesheet and rely on degradation, author it so that dropping the @layer wrappers still yields sane precedence. The trick is to keep the source order of your rules aligned with your intended layer order, so an engine that ignores the wrappers gets the right winner by source order alone:

/* Declare native order for modern engines. */
@layer reset, base, themes, components, utilities, overrides;

/* Author each layer's rules in the SAME sequence as the declaration.
   Modern browsers obey @layer; legacy browsers ignore the wrappers and
   fall back to source order — which we've deliberately kept identical,
   so 'overrides' rules still appear last and still win. */
@layer reset      { :where(*) { box-sizing: border-box; } }
@layer base       { body { line-height: 1.6; } }
@layer components { :where(.btn) { background: #0055ff; color: #fff; } }
@layer utilities  { .text-center { text-align: center; } }
@layer overrides  { .checkout .btn { inline-size: 100%; } }

This is not a perfect emulation — source order and layer order diverge the moment specificity enters the picture — but for reset-heavy, low-specificity design systems it degrades remarkably gracefully. It pairs well with the discipline described in resolving third-party CSS conflicts, where source order is already a tool you manage deliberately.

Integration and tooling

Browserslist and caniuse: deciding whether you even need a fallback

Every decision on this page starts with data, not vibes. Query your own analytics against the @layer support baseline, then encode the answer once in Browserslist so your whole toolchain agrees on it:

# See exactly which browsers your current Browserslist config resolves to.
npx browserslist

# Check the coverage your config represents against global usage —
# if 'and_ff' or old Safari fall outside it, no polyfill is needed.
npx browserslist --coverage

# caniuse-cli gives you the raw support table for the feature.
npx caniuse-cli @layer

If npx browserslist returns nothing older than Safari 15.4 / Chrome 99 / Firefox 97, you are on native everywhere and can delete the polyfill entirely. That single command is the most reliable answer to “can we ship this?” — far better than guessing.

Vite

Vite reads postcss.config.js automatically, so Pattern 1 requires no Vite-specific wiring. The one thing to get right is that Vite code-splits CSS per chunk, which can scatter your @layer declaration. Anchor the canonical order in a stylesheet imported before anything else:

// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  css: {
    // Vite auto-detects postcss.config.js; the cascade-layers polyfill
    // there runs across every chunk. Nothing else to configure —
    // just ensure your layer-order declaration is imported first
    // (e.g. in main.ts before any component styles).
    devSourcemap: true,
  },
});

Stylelint

Guard the two failure modes that quietly break layered CSS on all browsers, native or polyfilled: an unlayered @import that jumps ahead of your order, and stray !important that fights precedence the layer stack should own.

// .stylelintrc.json
{
  "rules": {
    // A bare @import must appear before other rules; a mis-positioned one
    // can slip third-party CSS in as unlayered author styles that beat
    // every named layer. This rule catches it in CI.
    "no-invalid-position-at-import-rule": true,

    // Layer order should make author-style !important unnecessary.
    // Flag it so precedence stays a layer decision, not an escalation.
    "declaration-no-important": true
  }
}

For a build that automates this end to end, the build-pipeline layer automation guide in the architecture section wires the polyfill, Stylelint, and Browserslist into a single CI gate.

Common pitfalls and anti-patterns

Anti-pattern: shipping the polyfill forever

The polyfill is a bridge, not a dependency. Leaving it in after your Browserslist matrix has moved past 2022 engines means every visitor pays the specificity-inflation cost for a fallback nobody needs.

// BAD: hard-coded ancient targets keep the polyfill emitting bloat forever.
require("@csstools/postcss-cascade-layers")({ /* targets never revisited */ });

// GOOD: drive it from Browserslist and audit that query quarterly.
// When `npx browserslist` shows only 2022+ engines, delete the plugin.

Anti-pattern: rules outside the layer as your “fallback”

Putting fallback rules outside any @layer block does not degrade gracefully — it does the opposite. Unlayered author styles beat every named layer on modern browsers, so your “fallback” silently overrides your real design system everywhere.

/* BAD: this "fallback" is unlayered, so it wins on modern browsers too,
   clobbering the layered .btn it was meant to back up. */
.btn { background: gray; }
@layer components { :where(.btn) { background: #0055ff; } }

/* GOOD: gate the fallback behind a negative feature query so it only
   exists where @layer does not. */
@supports not (at-rule(@layer)) { .btn { background: gray; } }
@supports at-rule(@layer) {
  @layer components { :where(.btn) { background: #0055ff; } }
}

Anti-pattern: assuming @supports (at-rule(@layer)) covers every old browser

The at-rule() query is newer than @layer. A browser old enough to lack layers may also lack at-rule() detection and fall through both branches, rendering unstyled. For deep-legacy support, link a separate flat stylesheet or use the polyfill instead of relying solely on the feature query.

Anti-pattern: treating @layer as a replacement for scoping

Cascade layers order precedence; they do not scope selectors. A .title in one layer and a .title in another still target the same elements — layers just decide which wins. If your actual problem is name collisions, you want CSS Modules or naming discipline, covered in the comparing cascade layers to alternatives guide, not layers alone.

Anti-pattern: migrating 100% at once

Wrapping every legacy file in @layer in a single PR guarantees a hard-to-review diff and a scary regression surface. Migrate origin-in: move third-party CSS into a vendor layer first, then your reset, then components, verifying precedence at each step. The architecture patterns and design system scaling section documents the incremental extraction order.

Browser compatibility

Browser @layer support Baseline status
Chrome / Edge 99+ (1 March 2022) Baseline Widely Available
Firefox 97+ (8 February 2022) Baseline Widely Available
Safari 15.4+ (14 March 2022) Baseline Widely Available
Safari iOS 15.4+ (March 2022) Baseline Widely Available
Samsung Internet 19+ Supported
Any pre-2022 engine none Use polyfill or fallback

All three major engines shipped @layer within a five-week window in early 2022, and the feature has been Baseline Widely Available since roughly September 2024. The @supports at-rule(@layer) feature query and the @csstools/postcss-cascade-layers polyfill exist to cover the shrinking tail of engines older than that window. For most product teams in 2026, native @layer with no tooling is the correct default; reach for the polyfill only when your own Browserslist data says a meaningful audience predates March 2022.

FAQ

Is CSS @layer safe to use in production without a polyfill?

For most teams, yes. @layer reached Baseline Widely Available status in mid-2022: Chrome and Edge 99, Firefox 97, and Safari 15.4 all shipped it in a window between February and March 2022. Baseline Widely Available means the feature has been interoperable across all major engines for at least 30 months, so any browser released in the last few years supports it natively. You only need a polyfill if your analytics show meaningful traffic from Safari below 15.4, Chrome below 99, or other engines predating that window.

How does the postcss-cascade-layers polyfill work in browsers without @layer?

The polyfill reads your declared layer order at build time and rewrites every selector so its specificity encodes that order. Rules in a higher-priority layer receive extra zero-specificity :not(#\#) prefixes to raise their weight above lower layers, while the @layer at-rules themselves are stripped out. The output is plain CSS that produces the same winner as native layers, at the cost of inflated selector weight — so it is meant to be removed once your support matrix no longer includes pre-2022 engines. The mechanics connect directly to calculating selector weight in layers.

How do I feature-detect @layer support in CSS?

Use @supports at-rule(@layer) { … }. The at-rule() function inside @supports tests whether the browser recognises a given at-rule, so the block only applies where cascade layers are understood. You can pair it with @supports not (at-rule(@layer)) to ship a fallback stylesheet for engines that lack support. Note that at-rule() feature queries are themselves newer than @layer, so treat them as a progressive enhancement rather than a universal gate.

Should I migrate from BEM or CSS Modules to cascade layers?

They solve different problems, so it is rarely an all-or-nothing switch. BEM is a naming convention that flattens specificity; cascade layers give you an explicit precedence axis above specificity, so the two compose well — keep BEM names and wrap them in layers. CSS Modules scope class names at build time to prevent collisions, which layers do not do; a common pattern is to keep CSS Modules for scoping and add @layer to control cross-module precedence. Shadow DOM provides true style encapsulation that layers cannot replicate. The comparing cascade layers to alternatives guide breaks down each trade-off in full.

Does the @layer polyfill change how !important behaves?

It tries to preserve native semantics, but with caveats. Under native layers, !important inverts layer order: an !important rule in a lower-priority layer beats a normal rule in a higher one. The polyfill emulates this by adjusting the specificity chains it generates for important declarations, but because it is simulating a separate cascade axis using specificity alone, edge cases with deeply nested layers or heavy !important usage can diverge from native behaviour. The safest migration path is to keep !important out of author styles entirely and rely on layer order — see the role of !important in layers.


Topics in This Section

@layer Browser Support and Polyfills The complete support matrix, how the @csstools/postcss-cascade-layers polyfill rewrites layer order into specificity, and the feature-detection and fallback patterns for shipping to browsers older than the March 2022 baseline.

Comparing Cascade Layers to Other Scoping Strategies A side-by-side look at @layer versus CSS Modules, Shadow DOM, and BEM — what each one actually solves (precedence, scoping, or encapsulation), where they overlap, and how to combine them instead of choosing one.