# CSS @layer in Practice: Cascade Layers Instead of Specificity Wars

> Cascade layers from the ground up: syntax, cascade order, frameworks like Bootstrap and Tailwind in their own layer, shadow DOM and design tokens.

Source: https://www.jpkc.com/db/en/blog/css-cascade-layers/

There is one line of CSS almost everyone has written and regretted afterwards:

```css
.sidebar .widget ul li a.link { color: #fff !important; }
```

That line is not styling. It is a surrender. Someone wanted to change a colour, lost to a selector, prepended another class name, lost again, and finally reached for `!important`. The result works — until somebody else plays the same game one level up.

Since March 2022 there has been a proper answer to this in every browser engine: **cascade layers**, the `@layer` at-rule. At-rules are CSS statements starting with an `@` — `@media`, `@import`, `@supports`; they either end in a semicolon or wrap a block of their own. This one adds a step to the CSS cascade that sits *above* specificity. (The cascade is the algorithm a browser runs to pick a winner among competing values for the same property — it is not inheritance.) Instead of inflating selectors, you state once in which order your style tiers apply — and from then on the later tier wins, no matter how weak its selector is.

This article is the long tour: the rules (each one measured in a real browser rather than copied from documentation), migrating a website that actually grew over years, frameworks like Bootstrap and Tailwind in their own layers, the relationship with web components and shadow DOM — and a pattern that lets design tokens carry their own layer assignment.

> **A note on method:** every behavioural claim in this article was checked against a real browser (Chromium engine, Edge 151, headless, `getComputedStyle`). Where documentation and specification disagreed, the measured result is what you read here — that actually happened once, further down.

## The problem: specificity is a poor ordering system

Before `@layer` existed, you had exactly three levers to decide which of two competing declarations wins. A **declaration** here means exactly one `property: value;` pair; a **rule** is the selector plus the braced block holding one or more declarations. The three levers:

1. **Origin and importance** — browser stylesheet, user stylesheet, author stylesheet, plus `!important`.
2. **Specificity** — the familiar count of ID, class and element selectors. The browser counts three slots per selector: IDs — classes, attributes and pseudo-classes — elements and pseudo-elements. `#hint` comes to 1-0-0, `.box.box.box` to 0-3-0, `div.box` to 0-1-1. They are compared left to right; the higher value wins.
3. **Source order** — on a tie, the last declaration wins.

All three are perfectly adequate for small stylesheets and unfit for large projects. The reason is structural: **specificity is a property of the selector, not of the intent.** What you want to say is "this rule belongs to my design system and components may override it". What you are able to say is "this rule has two classes and one element". Those are entirely different statements, and CSS forces you to use the second one to mean the first.

Hence the well-known trajectory of every larger project:

- First selectors get longer, in order to win.
- Then `!important` appears, because selectors no longer suffice.
- Then `!important` appears in the other direction, because the first one is in the way.
- In the end nobody dares delete anything.

Methodologies like BEM (Block Element Modifier), SMACSS (Scalable and Modular Architecture for CSS) or ITCSS (Inverted Triangle CSS) are all attempts to solve this with **discipline** — through conventions for naming and file order: keep specificity flat, order the file so that generic things come first and important ones sit at the bottom. That works as long as everyone plays along — and breaks the moment third-party CSS enters, which by its nature does not follow your convention.

Cascade layers solve the problem at the right place: they make **intent** a first-class part of the cascade.

## What cascade layers actually are

A cascade layer is a named bucket for CSS rules. You define the order of the buckets once, and from then on:

> **Among competing normal declarations, the bucket declared later wins — regardless of the specificity of the selectors inside it.**

That is the entire core idea. Everything else is detail and edge cases.

The minimal example:

```css
@layer base, components;

@layer base {
	#notice { color: red; }         /* specificity 1-0-0 */
}

@layer components {
	.notice { color: green; }       /* specificity 0-1-0 */
}
```

The element renders **green**. The ID selector loses to a class selector because `components` was declared after `base`. In classic CSS that was simply impossible.

### The cascade in full

To use `@layer` safely you need to know *where exactly* layers sit in the cascade. The specification ([CSS Cascading and Inheritance Level 5](https://www.w3.org/TR/css-cascade-5/)) sorts competing declarations in this order — the first criterion that makes a difference decides:

1. **Origin and importance** (browser / user / author, each normal and `!important`)
2. **Encapsulation context** (shadow DOM tree — a whole chapter on that below)
3. **Element-attached styles** (the `style` attribute)
4. **Layers**
5. **Specificity**
6. **Order of appearance**

Two things about this matter in practice and are frequently missed:

**Layers rank above specificity, but below inline styles.** A `style="color: red"` in the HTML beats every layer. `@layer` therefore does not replace the rule "no inline styles in markup".

**The encapsulation context ranks above layers.** This is why `@layer` and shadow DOM behave differently than most people first assume — the chapter on that comes later.

And here is the complete ranking in the author part of the cascade, from weakest to strongest — the two animation ranks are strictly their own origins reaching into it:

| Rank | What |
|---|---|
| 1 (weakest) | First layer, normal |
| 2 | Second layer, normal |
| … | further layers, normal |
| 3 | **Unlayered** rules, normal |
| 4 | Inline styles, normal |
| 5 | Running CSS animations |
| 6 | **Unlayered** rules, `!important` |
| … | further layers, `!important` — **in reverse** |
| 7 | Second layer, `!important` |
| 8 | First layer, `!important` |
| 9 | Inline styles, `!important` |
| 10 (strongest) | Running CSS transitions |

The two rows that cause bugs are rank 3 and rank 6 — more on both shortly.

## The syntax: five forms, that is all

### 1. The order declaration

```css
@layer reset, base, components, utilities;
```

This line creates four empty layers in exactly that order. It belongs at the very top of your stylesheet — it is the contract the rest of the file fulfils. Where the rules physically live afterwards no longer matters.

That is the single most useful trick of the whole at-rule: **order is fixed by the first mention of a layer name, not by the position of the rules.** So you can keep sorting your file the way humans read it.

### 2. The layer block

```css
@layer components {
	.btn {
		border-radius: 0.4rem;
		padding: 0.5em 1em;
	}
}
```

You may open the same layer as often as you like. The browser merges all blocks of the same name into one layer; inside the layer, specificity and source order apply again as usual.

```css
@layer components { .btn { padding: 0.5em 1em; } }
@layer utilities  { .p-0 { padding: 0; } }
@layer components { .card { padding: 1rem; } }   /* still sorts before utilities */
```

That is not sloppiness but the intended way to work with files that already exist — it is exactly how the jpkc.com migration described below was done.

### 3. Anonymous layers

```css
@layer {
	/* rules without a layer name */
}
```

A block without a name creates a layer nobody can reference, extend or reorder afterwards. Every additional `@layer { }` creates a *new* anonymous layer. Useful for code you deliberately want to seal off; unsuitable for anything you still need to touch.

### 4. Importing into a layer

```css
@import url("bootstrap.css") layer(framework);
@import url("legacy.css") layer;            /* anonymous layer */
```

Note the second line: for an anonymous layer the keyword stands **without parentheses**. `layer()` with empty parentheses looks plausible but is not a valid value — and the error handling is what makes it nasty: the whole `@import` becomes invalid, the stylesheet does **not load at all**, and no console error appears. Measured: the imported rules are simply gone.

This is how you get a foreign stylesheet into a layer without touching its contents. It is the core of the framework chapter below.

One thing about document order: `@import` must come before all other rules — **with exactly two exceptions**, `@charset` and `@layer` **statements**. That means the statement form `@layer a, b;` only. A `@layer name { … }` **block** placed before it invalidates every following `@import`; the specification notes explicitly that layer blocks cannot be interleaved with `@import` rules. So the following is valid, and it is the recommended way to write it:

```css
@layer framework, app;                          /* pin the order first */
@import url("bootstrap.css") layer(framework);  /* then import */
```

### 5. Nested layers

```css
@layer components {
	@layer card { /* … */ }
	@layer button { /* … */ }
}

/* equivalent, using dot notation: */
@layer components.card { /* … */ }
@layer components.button { /* … */ }
```

Nested layers create a sub-ranking inside their parent. The name is bound to the parent: `components.card` and `utilities.card` are two entirely different layers.

The point is namespacing. A framework can use `framework.reset`, `framework.base`, `framework.components` internally without those names colliding with your own `reset`/`base`/`components`.

## Eight rules you need to know

The following eight points are what actually causes surprises in practice. Each one comes with the measured result from a real browser.

### Rule 1: layers beat specificity — always

```css
@layer a, b;
@layer b { #box { color: blue; } }                 /* 1-0-0 */
@layer a { .box.box.box { color: red; } }          /* 0-3-0 */
```

**Measured: blue.** Inside a layer, specificity still counts exactly as before. Between layers it counts for nothing at all.

This is the property you adopt `@layer` for: in a later layer, a single class selector can override something the earlier layer set with an ID selector.

### Rule 2: order is fixed at first mention

```css
@layer a { #box { color: red; } }
@layer b { #box { color: green; } }
@layer b, a;                                       /* too late */
```

**Measured: green.** The two blocks already created the layers in the order `a, b`. The trailing declaration line changes nothing — it merely mentions two layers that already exist.

The practical consequence: **an `@layer` order line only has an effect if it precedes every affected block.** So it belongs on the first line of your entry stylesheet — and in exactly one file, not several.

### Rule 3: unlayered styles beat every layer

```css
@layer everything { #box { color: blue; } }        /* 1-0-0, layered */
div.box { color: green; }                          /* 0-1-1, unlayered */
```

**Measured: green.** Unlayered rules behave as if they lived in an implicit final layer — and the final layer wins.

That is both very handy and very dangerous:

- **Handy**, because in an existing codebase you can start pulling parts into layers without the still-unlayered remainder suddenly becoming weaker. Migration without a big bang.
- **Dangerous**, because every file you *forget* to layer automatically gets top priority. A single forgotten `<link>` to a third-party stylesheet defeats your entire layer architecture. More on that below.

### Rule 4: `!important` reverses the order

This is the rule that causes the most confusion, and it is entirely consistent by design: in CSS, `!important` has always meant "invert the normal balance of power" — which is why user `!important` rules beat author ones. Layers follow the same principle.

```css
@layer a, b;
@layer a { #box { color: red   !important; } }
@layer b { #box { color: blue  !important; } }
```

**Measured: red.** With `!important`, the **earlier** layer wins.

And the follow-on:

```css
@layer whatever { #box { color: blue !important; } }
#box { color: red !important; }                    /* unlayered */
```

**Measured: blue.** Unlayered `!important` rules are the **weakest** of all author `!important` rules — the mirror image of rule 3.

Remember: a layer placed early is the weakest for normal declarations — and the strongest for `!important`. That double role is the lever behind the Bootstrap recipe below.

### Rule 5: nested layers behave like their parent in miniature

```css
@layer c.card   { #box { color: red;   } }
@layer c.button { #box { color: green; } }
```

**Measured: green.** Inside `c` the same logic applies: declared later wins.

And the edge case worth seeing once:

```css
@layer c;
@layer c.child { #box { color: red;   } }
@layer c       { #box { color: green; } }   /* directly in c, not in a child */
```

**Measured: green.** Rules that sit *directly* in the parent count as an implicit final sub-layer — so they beat every named child. With `!important` this reverses too (then the first child wins).

### Rule 6: `revert-layer` hands control back to the previous tier

```css
@layer base, theme;
@layer base  { .btn { color: green; } }
@layer theme { .btn { color: red; }
               .btn.plain { color: revert-layer; } }
```

**Measured: green** for `.btn.plain`. The value is not reset to its initial value (that would be `unset` or `initial`) but to whatever the *previous* tier would have set.

Two edge cases, both measured:

- **`revert-layer` in the lowest layer** falls all the way through to the browser stylesheet. An `<a href>` with `color: revert-layer` in the only layer resolved to `rgb(0, 0, 238)` — the browser's default link blue.
- **`revert-layer` in unlayered CSS** falls back to the tier below — that is, the last explicit layer that **actually sets** the property, not the browser stylesheet. Tested with two layers `l1` (red) and `l2` (green) plus an unlayered `color: revert-layer` — result: **green**, the value from `l2`. If `l2` does not set the property, it falls through to `l1`; if no layer sets it, you do end up at the browser stylesheet after all.

  The second point is notable because widely circulated summaries claim otherwise (namely a blanket fallback to the browser stylesheet). The measured behaviour matches the specification: unlayered CSS *is* the implicit final layer, so "previous layer" is the last named one. Nor is it a Chromium quirk — the Web Platform Tests cover exactly this case (`css/css-cascade/revert-layer-002.html`), and it passes in Chrome, Firefox and Safari.

And a limit: `revert-layer` is a normal declaration. It cannot beat an `!important` from another layer — measured as well.

### Rule 7: a layer that was only declared still holds its slot

```css
@layer first, second;
@layer second { #box { color: green; } }
@layer first  { #box { color: red;   } }
```

**Measured: green.** The declaration line created both layers; the fact that `second` is filled first in the text is irrelevant.

One detail for the advanced: layers created *inside* a `@media` condition only come into existence when the condition matches. Your layer order then depends on the viewport. That is rarely intended — always declare the order outside any conditional block.

### Rule 8: animations and transitions outrank everything

```css
@layer everything { #box { color: red; } }
#box { animation: shift 100s step-end forwards; }
@keyframes shift { from { color: green; } to { color: green; } }
```

**Measured: green.** A running animation beats every normal declaration, layered or not; a running transition beats even `!important`. That is not layer-specific, but it explains a share of the cases where `@layer` appears "not to work".

## Practice 1: migrating a site that grew

The case I can document best is the main site, [jpkc.com](https://www.jpkc.com/en/). Its CSS grew over years, carries a Bootstrap 4 legacy in the reset, and is written **as a single file inlined into every page** (around 1,900 lines, minified with `lightningcss`). A textbook candidate: lots of existing code, no appetite for a rewrite.

The migration was a single commit and consisted of exactly two moves.

**First**, pin the order, right after the custom properties:

```css
@layer reset, base, components, utilities, responsive;
```

**Second**, wrap the existing sections in blocks — without moving a single rule:

| Layer | What went in |
|---|---|
| `reset` | `html`, `body`, `:target`, scrollbars, the `:focus-visible` baseline |
| `base` | typography, links, code, media, tables, forms, `::selection` |
| `components` | buttons, dropdown, SVG icons, header, main area, navigation, `<details>`, footer |
| `utilities` | `.visually-hidden`, spacer, `.box`, directory-tree list, light toggle |
| `responsive` | all `@media` blocks — breakpoints, `inverted-colors`, print |

The file kept its section order. Because multiple blocks of the same name merge (see the layer block rule), `@layer components { … }` appears at several points in the file — that is intended, not a mistake.

Three decisions from it generalise:

**Tokens stay unlayered.** The `:root` block with all custom properties sits *before* the layer declaration and outside every layer. (Custom properties are CSS variables: declared as `--name: value`, read with `var(--name)`; they inherit to descendants. `:root` is the pseudo-class matching the document's root element, in HTML the `<html>` tag.) That gives design tokens unconditional priority so no component can accidentally override them. Whether that is the right call depends on the project — the design tokens chapter below argues the opposite position.

**`responsive` as its own layer is the real win.** Previously: a `@media` block overrode a component rule only if it appeared later in the source *and* had at least equal specificity. Both had to be kept in mind while editing. Now the media block always wins because its layer is last — even with a simpler selector. That had been failure mode number one when touching old breakpoints.

**The `!important` reversal was deliberately recorded.** In the new order, the `!important` in `.visually-hidden` (layer `utilities`) is *stronger* than any `!important` in the print stylesheet (layer `responsive`). There is currently no collision because the two set different properties — but exactly this kind of constellation needs a pass and a note during migration. If you carry a lot of `!important`, inventory it before you start.

**And the cost?** Exactly +83 bytes per page after minification — the same 83 bytes on every page, because it is simply the at-rule lines. For a file inlined into every HTML document in full, that works out to between 0.06 % (home page) and 0.18 % (shortest subpage), depending on page length. For external stylesheets, compression makes the effect smaller still. `@layer` costs practically nothing.

One note on trusting your toolchain: `lightningcss` **preserves** layer semantics when minifying; it does not strip the at-rules. A minifier rewrites CSS for minimum size — merging adjacent rules, folding longhand into shorthand — and `lightningcss` is one such tool, written in Rust. That is not a given and should be verified once against the output of whatever build tool you use — a minifier that "optimises away" `@layer` blocks inverts your entire cascade.

### The migration recipe in short

1. Declare the `@layer` order at the very top.
2. Wrap existing sections *in place*; move nothing.
3. Place custom properties deliberately (inside or outside — but decide).
4. List every `!important` in the project and re-check the reversal.
5. Build, then check the minified output for surviving `@layer` blocks.
6. Diff visually. If the order is chosen correctly, nothing changes — which is precisely the goal.

## Practice 2: putting Bootstrap in its own layer

The real headline use case for `@layer` is third-party CSS. Take Bootstrap as the example, because it is the most widespread and because its behaviour is nicely measurable.

I downloaded the current distribution and counted — Bootstrap 5.3.8, `dist/css/bootstrap.css`, around 280 KB:

- **`@layer` occurrences: 0.** Bootstrap 5 ships no cascade layers. Everything lands unlayered — and by rule 3 that puts it *above* everything you put into layers.
- **`!important` occurrences: 1,716.** The lion's share comes from the utility API — a Sass generator that turns a configuration map into classes like `.m-1` or `.text-danger`. A **utility class** has exactly one purpose and is combined with others in the markup; `.mt-0` sets `margin-top: 0`, `.text-center` centres the text. Every generated utility class carries `!important` by default so it reliably overrides its component.

Those two numbers describe the problem precisely. Anyone customising Bootstrap is not fighting its specificity but its position in the cascade, and 1,716 `!important` declarations.

### Step 1: the framework into an early layer

```css
@layer framework, app;

@import url("bootstrap.css") layer(framework);

@layer app {
	.btn {
		border-radius: 0;   /* wins — no !important, no selector inflation */
	}
}
```

**Measured: works.** A single class in `app` beats Bootstrap's component rules regardless of how they are built. The whole toolbox of "prepend another class" and "`.btn.btn.btn`" becomes unnecessary.

This is the point where working with a framework changes qualitatively: you no longer need to know its selectors in order to override them. You only need to know that your layer comes later.

### Step 2: the `!important` trap

Now the catch most tutorials leave out. By rule 4 the layer order reverses for `!important`. Bootstrap sits in `framework`, the *earliest* layer. Therefore:

**In this arrangement, Bootstrap's 1,716 `!important` declarations are stronger than your own `!important` declarations in `app`.**

Tested with Bootstrap's `.text-danger { color: … !important }` against an `!important` in the later `app` layer: **Bootstrap wins.** That is not a bug but the logical consequence of rule 4 — and it reliably surprises everyone meeting it for the first time.

### Step 3: the override layer

The fix is a layer that sits **before** the framework and exists purely for `!important` counterfire:

```css
@layer bs-overrides, bootstrap, app;

@import url("bootstrap.css") layer(bootstrap);

@layer bs-overrides {
	/* Only !important rules meant to defeat Bootstrap utilities. */
	.text-danger { color: var(--brand-danger) !important; }
}

@layer app {
	/* Everything normal — beats Bootstrap without !important. */
	.btn { background: var(--brand); }
}
```

**Measured: both work.** `bs-overrides` wins the `!important` duel (earlier layer), `app` wins the normal duel (later layer). One layer per direction.

It looks like a trick at first, but it is defensible: you have two different intents — "I want to extend the normal case" and "I want to defeat a framework's emergency brake" — and in a cascade with a reversal, two intents need two slots.

**The better fix if you compile Bootstrap yourself:** simply turn off `!important` on the utilities. Bootstrap has a Sass variable for it — Sass being a CSS preprocessor with its own language (`$name` variables, mixins, nesting) that a build step compiles to CSS; SCSS is its CSS-like syntax:

```scss
$enable-important-utilities: false;
@import "bootstrap/scss/bootstrap";
```

Without `!important` on the utilities the entire second layer problem disappears and plain layer order suffices. The variable has existed since Bootstrap 5.0 and sits in `scss/_variables.scss` as `true !default`.

### The Sass pitfall

Anyone pulling Bootstrap in through Sass hits a real tooling limit: **`@use` may not appear inside a block in Sass.** (`@use` loads a Sass file as a module whose variables, mixins and functions are namespaced; `@forward` passes them on. Both replace the deprecated `@import`.) So this is not valid Sass:

```scss
@layer bootstrap {
	@use "bootstrap/scss/bootstrap";   /* error: @use must be at the top level */
}
```

A `layer()` argument for `@use` and `@forward` was proposed in 2022 but rejected within days — with the note that Sass already has a tool for this. And that tool is the clean route:

**`meta.load-css()` loads a Sass module right where you are** — including inside a `@layer` block:

```scss
@use "sass:meta";

@layer bootstrap {
	@include meta.load-css("bootstrap/scss/bootstrap");
}
```

Tested with Dart Sass 1.102.0: this compiles to `@layer bootstrap { … }` with the full framework CSS inside — and no deprecation warning. The difference from `@use` matters: `meta.load-css()` only emits the **CSS**; it does **not** give you the module's variables, mixins and functions. Those you still load at the top via `@use` — and that division of labour is exactly the point.

That leaves three workable routes, in this order:

1. **`meta.load-css()` inside the layer.** The official route, no expiry date, no extra file. First choice when the framework runs through your Sass pipeline anyway.
2. **Compile the framework separately.** Build Bootstrap into its own `.css` file and pull it in via `@import url(…) layer(bootstrap)` from a slim CSS entry point. Cleanest separation, independent of Sass — sensible when you do not customise the framework at all.
3. **Wrap after the build.** A PostCSS step puts the entire compiled framework output into a layer. Robust and toolchain-agnostic, at the cost of an extra build step.

The formerly common nested `@import` inside `@layer { }` still works but raises a deprecation warning — `@import` will be removed in Dart Sass 3.0. Do not use it in new code.

### The `<link>` pitfall

And now the mistake that silently makes an entire layer architecture worthless.

**A `<link rel="stylesheet">` cannot be assigned to a layer today.** There is no `layer` attribute for `<link>`. The corresponding proposal has been with the CSS Working Group for years but is implemented in no browser.

By rule 3 the consequence is harsh:

```html
<link rel="stylesheet" href="third-party.css">   <!-- unlayered -->
<style>
	@layer app { html body #box { color: blue; } }   /* specificity 0-1-0-2 */
</style>
```

```css
/* third-party.css */
#box { color: red; }                                 /* specificity 0-1-0-0 */
```

**Measured: red.** The weaker rule from the linked stylesheet wins because it is unlayered. Your carefully built layer order is powerless against it.

The workaround is an intermediate stylesheet: a tiny CSS file that does nothing but import the real files into layers.

```css
/* main.css — the only file loaded via <link> */
@layer reset, framework, base, components, utilities;

@import url("reset.css")      layer(reset);
@import url("bootstrap.css")  layer(framework);
@import url("base.css")       layer(base);
@import url("components.css") layer(components);
@import url("utilities.css")  layer(utilities);
```

But you have traded in a performance problem: `@import` chains resolve **serially**. The browser must fully load and parse `main.css` before it even learns that it needs `bootstrap.css` — and CSS blocks rendering, meaning nothing is painted until it has loaded and parsed (otherwise unstyled HTML would flash up). With five imports you are waiting on at least two full round trips — one round trip being a complete request-and-response to the server and back.

The workable answer for production: **bundle at build time.** Your bundler (esbuild, Vite, Lightning CSS, PostCSS — the last of these not a preprocessor with its own language like Sass, but a tool that rewrites CSS at build time through JavaScript plugins) resolves the `@import` statements and writes the `@layer` blocks straight into one file. You keep the clean source structure, the browser gets a single request. If you do leave `@import` in place at runtime, at least kick off the affected files with `<link rel="preload">`.

## Practice 3: Tailwind CSS v4 — the layers are already there

Anyone on Tailwind version 4 is already working with real cascade layers, whether they want to or not. Tailwind v3 had an `@layer` construct that only *looked* like the CSS at-rule: it was a PostCSS directive, evaluated and removed at build time. Version 4 uses native layers and writes them into the output.

This can be shown straight from the build of the site you are reading. The head of the generated `main.css` (Tailwind 4.3.0):

```css
/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
@layer properties { … }
@layer theme      { … }
@layer base       { … }
@layer components { … }
@layer utilities  { … }
```

Five layers in that order. The first of them is conditional: `properties` only appears when the generated utilities actually need registered custom properties — a minimal build without such classes emits just `theme, base, components, utilities`. What that means for your own code:

**Your own unlayered CSS beats every Tailwind utility.** That follows from rule 3 and is the most common cause of "my utility class has no effect". If you load a custom component file via `@import` alongside Tailwind and it sits in no layer, it beats `@layer utilities` — and therefore `p-4`, `text-center` and everything else.

The fix is to place your own CSS as well:

```css
@import "tailwindcss";

/* Sort custom components below the utilities so utility classes
   in the markup keep the last word. */
@layer components {
	.prose-card {
		border-radius: 0.75rem;
		padding: 1.5rem;
	}
}
```

**New utilities have their own route.** Tailwind v4 ships the `@utility` directive, which registers a class as a genuine utility — you write it straight into your CSS file (`@utility tab-4 { tab-size: 4; }`). That brings variant support (the `hover:` and `md:` prefixes that bind a utility to a state or a breakpoint) and correct placement into the `utilities` layer. A hand-written `@layer utilities { … }` does not get any of that.

**And a framework above the framework?** If you combine Tailwind with another third-party stylesheet, you can move the entire Tailwind output into a parent layer by layering the import:

```css
@layer vendor, tw, app;
@import "tailwindcss" layer(tw);
```

Tailwind's own layers then become `tw.theme`, `tw.base`, `tw.components` and `tw.utilities` — the internal order is preserved, and the block slots in as a whole between `vendor` and `app`. That is the nested namespacing from the syntax chapter in its most useful form.

**There is one exception**, measured against a real build: `properties` escapes the nesting. The layer stays at the top level and is declared even before `vendor` — making it the weakest in the entire document. Harmless, since it only supplies starting values, but it means only four of the five layers move into the namespace.

Which also settles what `properties` actually does: it holds a `@supports` block that emulates registered custom properties where the browser does not know them yet. The condition targets two engines at once — older Safari **and** older Firefox versions (before 128).

## Practice 4: third-party widgets, embeds and legacy code

The third classic use case is everything you neither control nor want to touch: embedded booking widgets, consent banners, chat windows, the CSS of an old section nobody understands any more.

The reflex here is the **anonymous layer** — and it makes a surprisingly easy trap visible. This example does **not** do what it promises:

```css
@layer app;                         /* ❌ app is now the FIRST layer */
@import url("widget.css") layer;    /* ❌ the anonymous layer is created AFTER it */
```

Measured: the widget wins. The explanation is rule 2 — order is fixed at first mention. `@layer app;` mentions `app` first and thereby makes it the weakest layer; the import's anonymous layer is created afterwards and is consequently stronger. Anonymous layers have exactly this quirk: because they carry no name, you cannot pull them forward in **any** order line. Their position follows solely from where the import sits.

The right move is therefore a **named** vendor layer placed at the front of the order line:

```css
@layer widget, app;                        /* foreign code first = weakest layer */

@import url("widget.css") layer(widget);

@layer app {
	/* your code — wins every normal contest */
}
```

**Measured: works.** You put the foreign code into a layer that sits at the front, and never have to mention it again. Nobody accidentally extends it, and your own CSS wins every normal contest.

If the layer really must stay anonymous, the import has to come **before** everything of your own — the order is then right, but you give up the ability to document it explicitly later. In practice the named layer is almost always the better choice.

For in-house legacy code a named layer is better:

```css
@layer legacy, modern;

@layer legacy {
	/* The old section, pasted in unchanged. */
}
```

The effect is a technical debt haircut: everything in `legacy` automatically loses against everything new. You can leave old code lying rather than defusing it — and pull it out piece by piece without priorities shifting in between.

## Web components: what layers can do — and what they cannot

A word on vocabulary first: **web components** is the umbrella term for your own HTML elements with encapsulated internals. A **custom element** is such a self-defined element (hyphenated name, registered via `customElements.define()`); attached to it is a **shadow DOM** — a separate, sealed-off DOM tree whose styles are scoped and which ordinary document selectors do not reach into.

This is where it gets interesting, because the common expectation is wrong. Anyone who understood `@layer` as "a priority system for CSS" assumes it can also govern the boundary between page and web component. It cannot — and for a very clear reason.

### The encapsulation context ranks above layers

Recall the sort order from the second chapter: **encapsulation context** is criterion 2, **layers** only criterion 4. Encapsulation context simply means which tree the declaration comes from — document or shadow tree. So the browser first checks which tree a declaration came from, and only reaches the layer question if both came from the same tree.

The rule for the encapsulation context reads:

> For **competing** declarations on the same element, **normal** declarations from the **outer** tree win, and **`!important`** declarations from the **inner** tree win.

The word "competing" carries weight here. The rule only applies where two declarations actually fight over the same element — that is, via `:host`, `::part()` and `::slotted()`. Everywhere the document cannot select the elements inside the shadow tree at all, the component wins anyway, and an inherited value loses to any shadow rule.

Measured on a custom element with an open shadow root:

```html
<style>
	#card { color: green; }               /* document, normal */
</style>
<my-card id="card">…</my-card>
```

```js
shadow.innerHTML = `<style>
	@layer a, b;
	@layer b { :host { color: red; } }    /* shadow tree, normal, last layer */
</style><slot></slot>`;
```

**Measured: green.** The document rule wins — even though the shadow rule sits in the later layer of its tree. The layer was never consulted.

And the counter-test with `!important`:

```html
<style>#card { color: green !important; }</style>
```

```js
shadow.innerHTML = `<style>:host { color: blue !important; }</style><slot></slot>`;
```

**Measured: blue.** Now the inner tree wins.

### Layer names do not cross the shadow boundary

The second key point: **every shadow tree has its own, entirely independent layer order.** A `@layer components` in the document and a `@layer components` in a shadow root are two different things that know nothing about each other.

Measured: if the document declares `@layer b, a;` while the shadow tree mentions `a` first and `b` second without an order line of its own, the inner first-mention is what counts — the document's order line has no effect whatsoever.

That is consistent with the purpose of shadow DOM: encapsulation is precisely the promise that outer structure means nothing inside. Two refinements belong here though, because the short version "layers and shadow DOM have nothing to do with each other" is too coarse in both directions.

**First: a global `@layer reset` does partly reach shadow trees.** Anything selector-bound stays outside — a `* { box-sizing: border-box }` from the document has no effect inside the shadow tree. **Inheritable** properties, however, flow in exactly as they always did: a `font-family: monospace` and a `letter-spacing: 3px` from the document reset were measurably in effect inside the shadow tree. Since a real reset consists of both, its typography half arrives in full.

**Second: a layer API to the outside is possible — but through the tree, not across the document boundary.** As long as your CSS lives in the document, you cannot slide a layer under the component; the encapsulation context decides first. If your stylesheet ends up *inside* the shadow tree, however — because the component accepts and adopts a `CSSStyleSheet`, or because its shadow root is open and you append a `<style>` — it shares the internal layer namespace. Measured: an injected `@layer a { … }` then genuinely joins the existing internal layer `a` and takes on its rank, instead of creating a new layer at the end.

That is the technical basis of the common theme-injection pattern — and a contract the component has to enter deliberately. The CSS Working Group is discussing further-reaching proposals here; do not build production code on them yet.

### `:host` is deliberately weak

From the context rule follows a property worth knowing when building components: **`:host` rules are easy for the consumer to override.** (`:host` selects, from the inside, the element the shadow root is attached to — and only works in styles that live inside that shadow root.) Any document rule matching the host element beats any normal `:host` rule — regardless of specificity and layers.

That is a deliberate platform design decision: `:host` sets *defaults*, not *mandates*. Trying to force the issue with specificity fails reliably — even `:host(.a.b.c.d.e)` and `:host-context(body)` lose against a plain `#id` in the document.

A component author who genuinely must pin something down (say `display` or `box-sizing`, so the layout does not break) has two options:

- **`!important` on `:host`** — and thanks to the context reversal, it is enforced. Just how strong that is shows in a side finding: `:host { … !important }` beats even an `!important` in the host element's `style` attribute. It is the one place in this entire article where inline styles lose — because the encapsulation context is checked before element-attached styles in the sort order.
- **Don't put it on `:host` at all.** Set the critical property on an inner wrapper inside the shadow tree instead. Without `::part()` the document simply has no selector that reaches it — the more robust route when all you need is for the layout not to break.

### So what are layers good for in web components?

Inside the shadow root, cascade layers are fully useful, and for exactly the same purpose as in the document: internal order.

A word on the mechanism used here: `adoptedStyleSheets` attaches a `CSSStyleSheet` built in JavaScript to a shadow root. The advantage over a `<style>` tag is that one sheet can be shared across many instances of the same component.

```js
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
	@layer defaults, parts, state;

	@layer defaults {
		:host { display: block; box-sizing: border-box; }
		button { all: unset; cursor: pointer; }
	}

	@layer parts {
		button { padding: 0.5em 1em; border-radius: 0.4rem; }
	}

	@layer state {
		:host([disabled]) button { opacity: 0.5; cursor: not-allowed; }
	}
`);
shadow.adoptedStyleSheets = [sheet];
```

The benefit is the same as everywhere: the state layer beats the parts layer without you having to make `:host([disabled]) button` artificially more specific. In a component with many states (`[disabled]`, `[loading]`, `[aria-invalid]`) that saves exactly the nested selectors that otherwise make component CSS unreadable.

One detail on `adoptedStyleSheets`: adopted stylesheets sort **after** the `<style>` elements of the same tree. On a tie in everything else, the adopted sheet wins. The interplay of adopted stylesheets, layers and partially open shadow roots is under active discussion in the CSS Working Group; that area is still moving and is no foundation for production code.

### The short version for component authors

| Question | Answer |
|---|---|
| Use layers *inside* the component? | Yes, fully worthwhile |
| Offer layers as a styling API to the outside? | Only through an explicit contract — an adopted stylesheet or an open shadow root |
| Write into a component layer from the document? | No — the encapsulation context decides first |
| Protect `:host` defaults from being overridden? | With `!important` — or don't put them on `:host` at all, but on an inner wrapper |
| Does the global layer order affect the component? | Its **internal** ranking, no. Everything arriving from outside (`::part()`, slotted content, inherited values), yes |
| What is the right styling API? | Custom properties and `::part()` — both work as before |

On `::part()`: the component marks inner elements with `part="label"`, and from outside you style them via `my-card::part(label) { … }`. That is the official route inward, explicitly opened by the component's author.

## Design tokens with layer metadata

Finally, an advanced pattern that moves the layer assignment to where it belongs: into the data source.

### The problem

When design tokens are hand-written as CSS, the layer question is trivial — you type `@layer` and you are done. Design tokens are named design decisions — colours, spacing, typography — kept centrally in one file, usually as JSON. In any serious design system, custom properties are **generated** from them through a build step. And that token file knows nothing about layers:

```json
{
	"card": {
		"background": { "$value": "#1c2935", "$type": "color" }
	}
}
```

The generator has no way of knowing that this token belongs to the card component and should therefore land in a component layer. So typically everything ends up in one big `:root` block — and the whole layer structure of the system stops at the token boundary. With twenty components nobody notices; with two hundred it becomes exactly the ordering problem `@layer` was supposed to solve.

### `$extensions` instead of a home-made key

The obvious reflex would be to invent a field:

```json
{ "$value": "#1c2935", "$type": "color", "cssLayer": "components.card" }
```

That is a bad idea, and the reason is in the specification. In the token format a leading dollar sign marks reserved keys — `$value`, `$type`, `$description` — and the [Design Tokens Format Module](https://www.designtokens.org/TR/drafts/format/), written by the Design Tokens Community Group (DTCG) at the W3C, provides the **`$extensions`** field for your own extra data — with one hard promise and one recommendation.

The hard promise is an obligation to preserve:

> "Tools that process design token files **MUST** preserve any extension data they do not themselves understand."

A tool that reads your token file and writes it back out **must** preserve extensions it does not know. For a home-made top-level key there is no such guarantee — it may vanish on the next pass through someone else's tool.

The recommendation concerns naming:

> "The keys **SHOULD** be chosen such that they avoid the likelihood of a naming clash with another vendor's data. The reverse domain name notation is recommended for this purpose."

Reverse domain notation means writing your domain's parts in reverse order: `jpkc.com` becomes `com.jpkc` — a namespace only one vendor owns. You know the pattern from Java and iOS.

A third passage in the same section is a caveat worth knowing: the specification asks you to restrict `$extensions` to **optional** metadata that is not crucial to understanding the token's *value*. That holds here — the colour `#1c2935` stands on its own without knowing which layer it belongs to. But it also means: if the extension is lost along the way, your build is not wrong, merely unlayered. Nobody spots that by looking — so let your CI check it.

A token file with layer assignments then looks like this — shown in full so you can follow the output below. The outer `color` group determines the prefix of the generated custom properties; `brand` deliberately carries no layer information and ends up in the unlayered `:root`:

```json
{
	"color": {
		"brand": { "$value": "#2f5468", "$type": "color" },
		"card": {
			"background": {
				"$value": "#1c2935",
				"$type": "color",
				"$extensions": {
					"com.jpkc/css-layer": { "layer": "components.card" }
				}
			}
		},
		"button": {
			"background": {
				"$value": "#8fb3c6",
				"$type": "color",
				"$extensions": {
					"com.jpkc/css-layer": { "layer": "components.button" }
				}
			}
		}
	}
}
```

The token value stays platform-neutral — for iOS or Android the layer information is simply unknown baggage that gets ignored (but preserved). Only the CSS generator evaluates it. That is cleaner than any solution writing CSS concepts into the token values themselves.

### The generator

The second part is a format hook that groups tokens by their layer metadata. In [Style Dictionary](https://styledictionary.com/) — the most widely used token compiler — that is about 30 lines. A token compiler reads your token file and emits per-platform output: CSS custom properties for the web, constants for iOS and Android. The following code was tested against Style Dictionary 5.5.1, not guessed:

```js
import StyleDictionary from 'style-dictionary';

const EXT_KEY = 'com.jpkc/css-layer';

StyleDictionary.registerFormat({
	name: 'css/variables-layered',
	format: ({ dictionary, options }) => {
		// Which shape the tokens have depends on the source file — SD reports it here.
		const dtcg = options.usesDtcg;
		const val = (t) => (dtcg ? t.$value : t.value);
		const ext = (t) => (dtcg ? t.$extensions : t.extensions) ?? t.original?.$extensions;

		const layers = new Map();
		const plain = [];

		for (const token of dictionary.allTokens) {
			const layer = ext(token)?.[EXT_KEY]?.layer;
			if (!layer) { plain.push(token); continue; }
			if (!layers.has(layer)) layers.set(layer, []);
			layers.get(layer).push(token);
		}

		const decl = (t, pad) => `${pad}--${t.name}: ${val(t)};`;
		let out = '';

		// Emit the order explicitly — otherwise token sorting decides it.
		if (layers.size) out += `@layer ${[...layers.keys()].join(', ')};\n\n`;
		if (plain.length) out += `:root {\n${plain.map((t) => decl(t, '\t')).join('\n')}\n}\n\n`;

		for (const [name, tokens] of layers) {
			out += `@layer ${name} {\n\t:root {\n${tokens.map((t) => decl(t, '\t\t')).join('\n')}\n\t}\n}\n\n`;
		}
		return out;
	},
});

const sd = new StyleDictionary({
	source: ['tokens/tokens.json'],
	usesDtcg: true,                        // optional — SD also detects DTCG itself
	platforms: {
		css: {
			transformGroup: 'css',
			buildPath: 'build/',
			files: [{ destination: 'tokens.css', format: 'css/variables-layered' }],
		},
	},
});

await sd.buildAllPlatforms();
```

The generated output:

```css
@layer components.card, components.button;

:root {
	--color-brand: #2f5468;
}

@layer components.card {
	:root {
		--color-card-background: #1c2935;
	}
}

@layer components.button {
	:root {
		--color-button-background: #8fb3c6;
	}
}
```

Two details separate "works in the example" from "works in the project":

**You mostly do not need to set `usesDtcg` — you just must not guess it.** Style Dictionary detects the DTCG format from the source file itself; leaving the option out produces byte-identical output (measured). Setting it explicitly only documents your expectation.

What is dangerous is the wrong assumption *inside the hook*. If a hook expecting `token.value` reads a DTCG file using `$value`, you get an output full of `--foo: undefined;` — no error message, exit code 0. And forcing the option against the source file (`usesDtcg: false` on a `$value` file) yields an **empty** output file, again without an error. That is exactly why the hook above reads `options.usesDtcg` instead of assuming either shape — it then works with old and new token files alike.

**The layer order must be emitted explicitly.** Without the leading `@layer …;` line, the order would emerge by accident from how tokens happen to be sorted in the source file. A renamed token could then upend the cascade — about the nastiest bug imaginable, because the cause lives in a JSON file and the effect in the rendered layout.

### The catch: layered tokens are weaker

And now the part that honestly belongs with this pattern, because it makes the result unusable in some projects.

The moment a token sits in a layer, rule 3 applies to it: **every unlayered `:root` rule beats it.** Measured:

```css
@layer theme { :root { --brand: red; } }
:root { --brand: green; }                /* unlayered */
```

The resolved value of `--brand` is **green**. Anyone switching themes through an unlayered block — an extremely common pattern — flattens every layered token in one stroke:

```css
/* Overrides every layered token without anyone noticing: */
@media (prefers-color-scheme: dark) {
	:root { --brand: #8fb3c6; }
}
```

From that follows a clear decision rule:

- **Leave tokens unlayered** if they are meant as immovable constants, read from everywhere and overridden nowhere. That is the choice made on jpkc.com — the `:root` block deliberately sits before and outside the layer declaration.
- **Layer the tokens** if themes, white labels or component variants are meant to override them by design — white-label meaning the same product ships under several customers' own brands, identical functionality, swapped colours and logos. But then do it **consistently**: theme overrides and every `prefers-color-scheme` block must be layered too, otherwise chance decides.

The mistake is the hybrid. Some tokens layered, others not — that produces exactly the class of bug that only shows up when switching to dark mode on one particular subpage.

## Which layer order? A concrete proposal

There is no canonical order, but there is one that has proven itself and that follows the logic of ITCSS at its core: **general to specific, foreign to own.**

```css
@layer overrides, vendor, reset, base, layout, components, states, utilities;
```

| Layer | Contents | Why here |
|---|---|---|
| `overrides` | Nothing but `!important` emergency brakes against third-party CSS | At the very front — for `!important` that is the strongest slot |
| `vendor` | Bootstrap, widgets, consent banners, everything foreign | Early, so everything of your own wins normally |
| `reset` | Normalisation, `box-sizing`, margin reset | Foundation for everything of your own |
| `base` | Element defaults: typography, links, tables, forms | Builds on the reset |
| `layout` | Grids, containers, page scaffolding | Carries components, is not overridden by them |
| `components` | Buttons, cards, navigation, everything reusable | Where most of the work happens |
| `states` | `is-open`, `[disabled]`, `[aria-current]`, error states | Must beat components without being more specific |
| `utilities` | Single-purpose classes: `.mt-0`, `.visually-hidden` | The last word in the markup — exactly what they are for |

Four notes on it:

**`states` as its own layer is underrated.** The classic pain of "`.card.is-active` does not override `.card__body .card__title`" disappears entirely.

**`responsive` usually does *not* belong in its own layer.** On jpkc.com it is one because all `@media` blocks historically live at the end of the file and the migration was meant to mirror that state. The cleaner route for new projects is to write media queries *inside* the layer they belong to — responsive button behaviour belongs to `components`. A dedicated `responsive` layer otherwise forces every breakpoint rule to beat every component rule, even where that was never intended.

**Fewer layers are better than more.** Four to six suffice for most projects. Every additional layer is one more rule the whole team has to carry in their heads.

**Unlayered CSS is a decision, not a state.** Because it beats everything, it should either not exist or have a deliberately documented role (for example: design tokens only).

## Debugging and tooling

**Chrome and Edge DevTools** show layers in the Styles panel: rules from a layer carry an `@layer <name>` label, and the *Toggle CSS layers view* button next to the search field gives you the element's full layer ranking — highest priority at the top, lowest at the bottom. When introducing layers this is by far the most useful tool, because it answers "why does this rule win?" directly.

**Firefox DevTools** likewise show layer membership in the rules panel.

**From JavaScript** the structure is inspectable: `CSSLayerStatementRule` (the order declaration) and `CSSLayerBlockRule` (the block) are ordinary CSSOM objects. The CSSOM (CSS Object Model) is the object tree the browser mirrors every stylesheet into — JavaScript reads `document.styleSheets` and the `cssRules` within it:

```js
for (const sheet of document.styleSheets) {
	for (const rule of sheet.cssRules) {
		if (rule instanceof CSSLayerStatementRule) console.log('Order:', rule.nameList);
		if (rule instanceof CSSLayerBlockRule)     console.log('Block:', rule.name);
	}
}
```

That lets you assert in CI — the automated pipeline that builds and tests on every commit — that the built output carries the expected layer order — a guarantee worth considerably more than any visual test, because it checks the *cause* instead of the effect.

For real use the loop has to descend, though: as written it only sees the top level and therefore misses layer blocks nested inside other layers, inside `@media`, and everything in imported stylesheets. It is only complete once it recurses into `CSSGroupingRule.cssRules` and `CSSImportRule.styleSheet`. Anonymous blocks report an empty `name` there.

## Browser support and migration

`@layer` has been available in every engine since **March 2022** and now carries the Baseline status *widely available*. Baseline is the WebDX Community Group's support rating, as shown on MDN, web.dev and Can I Use: *newly available* means the feature has landed in all core browsers, *widely available* the same thing 30 months later.

| Browser | From version |
|---|---|
| Chrome / Edge | 99 |
| Firefox | 97 |
| Safari | 15.4 |
| Opera | 86 |
| Samsung Internet | 18 |

One point here is critical and easily missed: **there is no meaningful fallback.** A browser that does not know `@layer` will, per the CSS error handling rules, ignore the at-rule **together with its entire block**. Your buttons would not be mis-prioritised there; they would be completely unstyled.

A feature query does not help here. There has been `@supports at-rule(@layer)` since Chrome 148, but that is exactly where the snake eats its tail: any browser that does not know `@layer` certainly does not know `at-rule()` either. Nor is there a runtime polyfill — a polyfill being code, usually JavaScript, that reimplements a missing platform feature so it can be used without native support. The cascade cannot be rebuilt that way, only by rewriting every selector. That is precisely what `@csstools/postcss-cascade-layers` (part of `postcss-preset-env`) does at **build time**, padding selectors with repeated `:not(#\#)` to reach the matching specificity.

Making it worse: in a browser without `@layer` support, `@import url(x.css) layer(y)` is an **invalid** `@import` too — so the stylesheet is not merely mis-prioritised there, it never loads at all.

The practical answer is nonetheless: with more than four years of availability across every engine this is a non-issue for almost any audience — but check your analytics before you migrate rather than assuming.

## Common mistakes — the checklist

| Symptom | Cause | Fix |
|---|---|---|
| Layer order has no effect | The `@layer a, b;` line comes after the blocks | Move the order declaration to the first line |
| Third-party CSS wins despite layers | It comes from a `<link>` and is unlayered | Import via a wrapper stylesheet or bundle at build time |
| Your own `!important` loses | The framework's layer comes earlier | Put a separate `overrides` layer *before* the framework |
| Utility classes have no effect | Your own CSS sits unlayered next to Tailwind | Place your CSS into `@layer components` |
| Dark mode flattens layered tokens | The `prefers-color-scheme` block is unlayered | Put theme overrides in the same layer as the tokens |
| Layer order changes per viewport | A layer is first created inside a `@media` condition | Declare all layers outside conditional blocks |
| `@layer` missing from the build output | The minifier strips the at-rules | Check the minifier and switch if in doubt |
| Shadow DOM styling ignores layers | The encapsulation context ranks above layers | Use custom properties and `::part()` instead of layers |
| Rule loses despite being in the last layer | Inline style, running animation or transition | Layers fundamentally do not apply there |

## FAQ

**Does `@layer` replace methodologies like BEM?**
No, it relieves them. BEM solves naming collisions and makes ownership readable in the markup; `@layer` solves priority conflicts. What `@layer` does make obsolete are the *specificity-driven* parts of such conventions — artificially doubling classes, counting up selectors, the rule "utilities must sit at the very bottom".

**Can I introduce `@layer` incrementally?**
Yes, and that is the recommended route. Because unlayered CSS beats everything layered, you can start by pulling only the third-party CSS into a layer. Your whole existing codebase stays exactly as strong as it was, and you still get the framework under control immediately.

**What happens with duplicate layer names across files?**
They refer to the same layer, provided both files live in the same tree. That is intended — it lets you spread `@layer components { … }` across many files. Inside shadow trees it does not hold: there, each tree is its own namespace.

**Are layers bad for performance?**
No. Measured on a genuinely inlined file: +83 bytes per page after minification — between 0.06 and 0.18 % depending on page size; the evaluation happens once while establishing stylesheet order, not per element. The only real performance aspect is serial loading in `@import` chains — and that is an `@import` problem, not a layer problem.

**How does `@layer` relate to `@scope`?**
The two complement each other and are independent. `@layer` answers "which tier wins", `@scope` answers "which part of the DOM does this rule target". In the sort order, scope proximity is **its own step, directly after specificity** — Level 5 of the specification does not know it yet, Level 6 adds it. That puts it far behind the layer decision, and a nearer scope root does **not** raise specificity: it only decides once specificity is tied. A `@scope` block may sit inside a layer and vice versa.

**What do I do about existing `!important`?**
Inventory it before introducing layers. Every `!important` behaves differently after the migration because it now follows layer order backwards. The ideal is to delete most of them — that is what `@layer` is for. What remains belongs in one deliberately placed `overrides` layer rather than scattered across the codebase.

## Conclusion

Cascade layers are one of the few CSS additions you understand in an afternoon and then use for years. The core is a single line:

```css
@layer reset, base, components, utilities;
```

From that moment on, "which rule wins?" is no longer a question about the selector but about the architecture. And that is the question you actually meant to ask.

The three points that stick from practice:

1. **Unlayered CSS beats everything layered.** That one rule explains roughly half of all surprises — and at the same time is what makes incremental migration possible in the first place.
2. **`!important` reverses the order.** Putting a framework in the first layer makes its `!important` rules unbeatable unless you plan an override layer ahead of it.
3. **Layers do not help against shadow DOM.** The encapsulation context ranks above them in the cascade. For web components, custom properties and `::part()` remain the styling API.

If you want to try it without touching a project: the [Playground](https://www.jpkc.com/db/en/tools/playground/) has an HTML/CSS editor with live preview — the examples from this article can be pasted in and modified directly.

## Glossary

Terms that appear in the text and would have overloaded the sentence there. If you know them, skip the table.

| Term | Meaning |
|---|---|
| `all: unset` | The `all` shorthand resets every property except `direction` and `unicode-bidi` — here it strips the browser's button styling. |
| Attribute selector | Square brackets match elements carrying an attribute: `[disabled]` any of them, `:host([disabled])` the host only when it does. |
| BEM notation | The double underscore separates block from element (`.card__body` is the card's body part); the `is-` prefix marks state (from SMACSS). |
| Breakpoint | A viewport-width threshold at which the layout switches. |
| `display` / `box-sizing` | Without an explicit `display` a custom element is `inline`, so `width` and `height` do nothing; `box-sizing: border-box` counts padding and border into the width. |
| Distribution (`dist/`) | A framework's precompiled CSS — the file you can layer via `@import`; the Sass sources live separately in `scss/`. |
| `@import "tailwindcss"` | The bare package name replaces `url()`: the build tool resolves it to Tailwind's CSS. In a browser it would be a dead relative URL. |
| `inverted-colors` | Accessibility media feature detecting colour inversion by the OS or user agent (`none`/`inverted`); in practice only Safari supports it. |
| Mixin | A named Sass building block pulled in with `@include`; the definition alone emits no CSS. |
| `platforms` / `buildPath` / `transformGroup` | Style Dictionary: `platforms` lists the output targets, `buildPath` is the output directory, `transformGroup: 'css'` bundles the conversions — including kebab-case names. |
| Print stylesheet | The `@media print` block for printed output. |
| Pseudo-class / pseudo-element | One colon = a state of the element (`:target`, `:focus-visible`); two colons = a rendered part with no DOM node (`::selection`). Legacy `:before` is the exception. |
| Reset | Unifies browser defaults and adds opinionated basics — usually `box-sizing: border-box` plus zeroed margins; Tailwind's version is Preflight, built on modern-normalize. |
| Selector | Decides which elements a rule hits: a space means "somewhere inside" (`.sidebar a`), no space means "the same element" (`.btn.plain`). |
| `<slot>` | The placeholder inside the shadow tree where the host's light-DOM children — whatever sits between its tags in the document — get rendered. |
| `.visually-hidden` | Hides text on screen but keeps it readable for screen readers. |

## Further reading

- [`@layer` — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) — reference for the at-rule
- [Cascade layers — MDN learning area](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Styling_basics/Cascade_layers) — in-depth introduction with examples
- [`revert-layer` — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/revert-layer) — the keyword for rolling back
- [CSS Cascading and Inheritance Level 5 — W3C](https://www.w3.org/TR/css-cascade-5/) — the specification, including the full sort order
- [Design Tokens Format Module](https://www.designtokens.org/TR/drafts/format/) — specification with the rules for `$extensions`
- [Style Dictionary](https://styledictionary.com/) — token compiler, basis for the generator example
- [Bootstrap: Sass options](https://getbootstrap.com/docs/5.3/customize/options/) — including `$enable-important-utilities`
- [Tailwind CSS v4](https://tailwindcss.com/blog/tailwindcss-v4) — announcement featuring the native cascade layers

**From this blog:**

- [Core Web Vitals: what really counts](https://www.jpkc.com/db/en/blog/core-web-vitals/) — why the size and load order of your CSS becomes measurable
- [Technical SEO](https://www.jpkc.com/db/en/blog/technical-seo/) — rendering, resources and crawling in context

