Configuring the PostCSS Cascade Layers Polyfill

Your layered stylesheet renders perfectly in every current browser, but the support matrix still lists Safari 15.2 and the layer precedence collapses there — this guide configures @csstools/postcss-cascade-layers so that precedence survives on pre-Baseline engines. It sits under Browser Support & Polyfills, part of Browser Support, Compatibility & Migration.

Prerequisites

Before configuring the polyfill, make sure you have:

  • A working PostCSS build (via postcss-cli, Vite, webpack’s postcss-loader, or similar).
  • A single upfront @layer declaration in your entry stylesheet — the plugin needs it to rank layers deterministically. If you are unsure why, review understanding layer declaration order.
  • A decision that a polyfill is actually warranted. The progressive enhancement for older Safari approach avoids the specificity cost entirely and is preferable when you only need a graceful fallback rather than exact precedence.
  • Tooling required: Node.js ≥ 18, postcss ≥ 8, and a .browserslistrc or browserslist key in package.json.

Step-by-step procedure

Step 1 — Install the plugin

Add the polyfill and PostCSS itself to your dev dependencies.

npm install --save-dev postcss @csstools/postcss-cascade-layers

What this does: Pulls in the transform and its peer. The plugin is part of the @csstools PostCSS Plugins family, so it follows the same versioning and option conventions as the rest of that suite.


Step 2 — Register the plugin in the right position

Add it to your PostCSS config after any plugin that inlines @import, so every layer is visible in a single flattened file when the transform runs.

// postcss.config.mjs
import postcssImport from 'postcss-import';
import cascadeLayers from '@csstools/postcss-cascade-layers';

export default {
  plugins: [
    // Inline @import FIRST so all @layer blocks live in one stream;
    // the polyfill cannot rank layers it never sees.
    postcssImport(),
    // Then flatten @layer into specificity-based selectors.
    cascadeLayers(),
  ],
};

What this does: Guarantees the polyfill operates on the complete, concatenated set of layers rather than one partial file at a time. Ordering it after postcss-import is the single most common configuration fix.


Step 3 — Declare the canonical layer order upfront

In your entry stylesheet, emit the full stack before any rule block. The plugin ranks layers by first appearance, identically to a browser.

/* entry.css — this line is the ranking key the plugin reads. */
@layer reset, base, themes, components, utilities, overrides;

@layer components {
  :where(.btn) { padding: 0.5rem 1rem; background: var(--color-primary); }
}

@layer utilities {
  .p-0 { padding: 0; } /* must out-rank .btn after the transform, too */
}

What this does: Fixes the precedence order deterministically. Without this line, the plugin would rank layers by the accident of rule-block order, which import concatenation can reshuffle — producing a build where utilities no longer beats components.


Step 4 — Set the reporting option

Enable the plugin’s warning output so the specificity changes it makes are visible during the build rather than silent.

// postcss.config.mjs (excerpt)
cascadeLayers({
  // 'warn' emits a PostCSS warning wherever the transform changes
  // a selector's specificity, so reviewers can see the impact.
  onImportLayerRule: 'warn',
}),

What this does: Surfaces every place the transform raises specificity. Treat these warnings as a review checklist: any unlayered author style that previously lost to a low-specificity rule may now behave differently.


Step 5 — Scope the polyfill with Browserslist

The transform ships to everyone, but Browserslist tells the wider toolchain which engines you are actually compensating for, keeping the decision auditable and time-boxed.

// package.json (excerpt)
{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "Safari >= 15",
    "not dead"
  ]
}

What this does: Documents the target matrix in one place. When the query no longer resolves to any pre-@layer engine, that is your signal — confirmed against analytics — that the polyfill can come out.


Step 6 — Build and verify the transform ran

Run the build and inspect the emitted CSS for the tell-tale specificity chains.

# Build, then grep the output for the plugin's signature selector chain.
npx postcss entry.css -o dist/bundle.css && grep -c ':not(#\\#)' dist/bundle.css

What this does: A non-zero count confirms the polyfill rewrote later-layer selectors with id-level specificity boosts. A count of zero means the plugin did not run — usually a plugin-ordering or config-loading problem from Step 2.


Verification

Confirm both that the transform is present and that it reproduces the intended cascade.

Inspect the output. Open dist/bundle.css and find a utilities rule. It should carry more :not(#\#) chains than the same-named components rule:

/* components rule — one boost */
.btn:not(#\#) { padding: 0.5rem 1rem; }
/* utilities rule — more boosts, so it wins by specificity in old engines */
.p-0:not(#\#):not(#\#) { padding: 0; }

DevTools trace in a pre-Baseline engine. Load the built file in Safari 15 (real device or Technology Preview against an older WebKit) and open Web Inspector → Elements → Styles. Select an element that both .btn and .p-0 target and confirm padding: 0 wins — the utility beats the component purely by the injected specificity, since this engine cannot read @layer.

DevTools trace in a modern engine. In Chrome, the same built file should still render correctly, though the Cascade Layers grouping will now show (unlayered) because the transform removed the @layer blocks. That is expected: the polyfilled file no longer contains native layers.


Troubleshooting

The output contains no :not(#\#) chains : The plugin did not run on the layered CSS. Check that it is listed after postcss-import in the config (Step 2) and that your config file is actually being loaded — run the build with --verbose or add a console.log to the config module to confirm.

Utilities stopped beating components after polyfilling : The upfront @layer declaration is missing or arrives after a rule block, so the plugin ranked the layers in the wrong order. Move the single @layer reset, base, themes, components, utilities, overrides; statement above every rule block, as in Step 3.

A previously-losing unlayered rule now wins (or loses) unexpectedly : The transform raised layered selectors’ specificity, changing how they compare against your unlayered author styles. Move the stray unlayered rule into the appropriate @layer block so it participates in the ranking, or review the Step 4 warnings to find every affected selector.

!important behaves differently in the polyfilled build : The native between-layer !important inversion is expressed through specificity by the polyfill and does not always match native rendering. Audit !important usage — per the role of !important in layers, a well-ordered stack rarely needs it, and removing it sidesteps the divergence.

Specificity is now too high to override anywhere : This is inherent to the approach: the polyfill trades layer semantics for specificity weight. If you find yourself unable to override polyfilled selectors, that is a strong signal your pre-Baseline audience has shrunk enough to remove the polyfill — measure and revisit.


Complete working example

A self-contained PostCSS setup that installs, orders, scopes, and reports the polyfill. Copy postcss.config.mjs, entry.css, and the browserslist block together.

// postcss.config.mjs — complete config
import postcssImport from 'postcss-import';
import cascadeLayers from '@csstools/postcss-cascade-layers';

export default {
  plugins: [
    // 1. Concatenate all @layer sources into one stream.
    postcssImport(),
    // 2. Convert layer order to specificity for pre-Baseline engines.
    //    'warn' reports every specificity change for review.
    cascadeLayers({ onImportLayerRule: 'warn' }),
  ],
};
/* entry.css — the source the plugin transforms */

/* Ranking key: the plugin reads layer order from this one line. */
@layer reset, base, themes, components, utilities, overrides;

@layer reset {
  *, *::before, *::after { box-sizing: border-box; }
}

@layer base {
  :root { --color-primary: #0055ff; }
}

@layer components {
  /* :where() keeps native specificity at 0; the polyfill adds its own
     boost on top so later layers still win in old engines. */
  :where(.btn) { padding: 0.5rem 1rem; background: var(--color-primary); color: #fff; }
}

@layer utilities {
  /* Must beat .btn everywhere — natively by layer order,
     and in old engines by the extra :not(#\#) the plugin appends. */
  .p-0 { padding: 0; }
}
// package.json (excerpt) — the support matrix in one auditable place
{
  "browserslist": ["> 0.5%", "last 2 versions", "Safari >= 15", "not dead"]
}

Build with npx postcss entry.css -o dist/bundle.css, then grep the output for :not(#\#) to confirm the transform ran.


FAQ

Why does the polyfill add :not(#\#) to my selectors?

#\# is a selector for an id equal to the literal character #, which no real element ever has, so it matches nothing while still counting as one id in specificity. Wrapping it in :not() keeps the match universal but adds that id-level weight. The plugin appends one such chain per layer step, so a rule in a later layer ends up with proportionally higher specificity than the same rule in an earlier layer — reproducing layer precedence for browsers that cannot read @layer.

Does the plugin need my @layer order declared upfront?

Yes. The plugin ranks layers by the order it first sees their names, exactly as a browser would. If you never write a single upfront @layer statement, the ranking falls back to the order rule blocks happen to appear, which import concatenation can scramble. Always emit one @layer reset, base, themes, components, utilities, overrides; statement before any rule block so the polyfill and native browsers agree.

When is it safe to remove the polyfill?

Remove it once your analytics show that traffic from Safari below 15.4 and Chromium below 99 has fallen under the threshold your team accepts — often well under one percent. Because the plugin’s transform ships to every visitor and raises specificity globally, removing it also simplifies debugging for the modern majority. Delete the plugin line, rebuild, and confirm the :not(#\#) chains are gone from the output.


Up: Browser Support & PolyfillsBrowser Support, Compatibility & Migration