Controlling Layer Precedence with the @layer Statement

The comma-separated @layer a, b, c; statement is the one line that decides your whole cascade before a single rule parses — this guide shows how to wield it, as one of the default layer ordering rules within CSS cascade fundamentals and layer syntax.

Prerequisites

You need to know that a layer’s precedence is set the first time the browser encounters its name, and that a later-registered layer beats an earlier one for normal declarations. The sibling guide on how the browser orders unnamed and named layers covers that registration mechanic in full; this page is about seizing it deliberately with a manifest statement.

The statement form — @layer name1, name2, name3; — is distinct from the block form @layer name { ... }. The statement carries no rules. Its only job is to register names, in order, all at once, so precedence is decided up front instead of emerging from whatever order your files happen to load.


Step-by-step: locking precedence up front

Step 1 — Write one manifest at the very top

Put a single comma-separated statement above every rule block and every @import. This is the moment all your layer names get registered, in the order you wrote them.

/* entry.css — first meaningful line of the whole build.
   Registers all six layers NOW, in ascending precedence. */
@layer reset, base, themes, components, utilities, overrides;

What this does: It fixes the complete precedence order before any rule or import can register a name on its own. Every later @layer reset { ... } or @import ... layer(components) now feeds an already-booked slot instead of creating a new one at an unpredictable position.


Step 2 — Read the list as ascending precedence

The manifest reads left-to-right as lowest-to-highest priority. The rightmost name is the strongest layer; the leftmost is the weakest.

@layer reset, base, themes, components, utilities, overrides;
/*      weak  →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→  strong  */

@layer reset      { a { color: blue; } }      /* loses to everything right of it */
@layer overrides  { a { color: red;  } }      /* wins: rightmost = highest */
/* Resolved color: red — overrides is later in the manifest than reset. */

What this does: It ties the visual order of the comma list directly to cascade outcome, so you can predict a winner by position alone. The deeper rationale for why sequence equals priority lives in understanding layer declaration order.


Step 3 — Reserve slots with empty layer names

A name in the manifest is registered even if no rules ever fill it. This “empty layer” reservation is the whole reason the statement exists: it books a slot before the rules arrive.

@layer reset, base, vendor, components, overrides;

/* 'vendor' has no block here, but its slot is already reserved between
   base and components. Rules can arrive much later — via @import below,
   from a dynamically injected stylesheet, or a code-split chunk — and
   they will land in that reserved position no matter when they load. */
@import url("https://cdn.example.com/ui-kit.css") layer(vendor);

What this does: It decouples precedence from load timing. Because vendor was pre-registered, a slow CDN import or a lazily loaded chunk cannot shove vendor rules to the top of the stack — the slot between base and components is already nailed down.


Step 4 — Fill layers below the manifest without disturbing order

Once names are registered, reopen any layer anywhere below the manifest to add rules. Reopening never re-registers or moves a layer.

@layer reset, base, components, utilities;

@layer components {           /* reopens the pre-registered slot */
  :where(.card) { padding: 1rem; }
}

@layer base {                 /* appears AFTER components in source... */
  :root { --gap: 1rem; }      /* ...but base stays lower — the manifest decided */
}
/* A components rule still beats a base rule; source order of the blocks
   is irrelevant because registration already happened in the manifest. */

What this does: It lets you organise rule blocks in whatever file order is convenient — even placing base physically after components — while precedence stays exactly as the manifest declared it.


Step 5 — Reorder the entire stack by editing one line

Because the manifest is the sole point of first registration, swapping two names in the comma list reorders those layers globally. No rule block changes.

/* BEFORE: utilities beats components (utilities is further right). */
@layer reset, base, components, utilities, overrides;

/* AFTER: swap the two names — now components beats utilities everywhere,
   and not a single selector or rule block was touched. */
@layer reset, base, utilities, components, overrides;

What this does: It turns a cross-cutting cascade decision — “should utilities win over components?” — into a one-line edit with a clean diff, instead of a codebase-wide specificity or !important sweep.


Verification

Confirm the manifest took effect and nothing pre-empted it.

  1. In DevTools → Elements → Styles, select an element styled by two competing layers. The layer subheadings appear in descending precedence; their top-to-bottom order should mirror your manifest read right-to-left.
  2. In the Computed tab, expand the contested property and read the [layer] tag on the winning declaration. It should be your rightmost-relevant manifest name.
  3. Enumerate registration order from the console to catch a name that registered before the manifest:
// Print author layer statements/blocks in source order.
// The FIRST occurrence of each name is what fixed its precedence.
for (const sheet of document.styleSheets) {
  for (const rule of sheet.cssRules) {
    if (rule.constructor.name === "CSSLayerStatementRule") console.log("statement:", rule.nameList);
    if (rule.constructor.name === "CSSLayerBlockRule")     console.log("block:", rule.name);
  }
}

If a block: line for some name prints before your statement: line lists it, that block pre-registered the name and your manifest ordering was ignored for it.


Troubleshooting

Manifest seems ignored — a layer sits in the wrong position : Something registered that name before the manifest ran: a rule block, or an @import layer(), physically above the statement. Move the comma-separated statement to the absolute top of the entry file, above all imports and blocks.

A layer I only imported is at the top of the stack : You did not reserve its slot. An @import layer(x) for an unreserved x registers x at the import position, which may be last (highest). Add x to the manifest at the intended position first, then import into it.

Editing the manifest changed nothing after a rebuild : The bundler likely concatenated another @layer statement or block ahead of yours, so a duplicate first registration won. Confirm your entry file is genuinely first in the build graph and that no injected reset registers layers earlier.

A code-split chunk reorders layers on lazy load : The chunk’s layer was not pre-registered in the initial manifest, so it registered late — and high — when the chunk loaded. Reserve every dynamically loaded layer as an empty name in the initial manifest.

Anonymous blocks refuse to obey the manifest : They cannot — an anonymous @layer {} has no name to list in the manifest, so it registers at its own source position and ignores the comma list entirely. Name the layer if you want the manifest to place it.


Complete working example

A single entry stylesheet that reserves every slot up front, fills layers out of order, imports a vendor layer into a reserved position, and demonstrates a one-line reorder. Paste it and inspect a .btn.

/* ============================================================
   entry.css — the manifest is the FIRST statement, on purpose
   ============================================================ */

/* Reserve all six slots now, ascending precedence left→right.
   'vendor' is empty here; its rules arrive by @import below. */
@layer reset, base, vendor, components, utilities, overrides;

/* Vendor rules land in the reserved slot between base and components,
   regardless of when this CDN response arrives. */
@import url("https://cdn.example.com/button-kit.css") layer(vendor);

/* Blocks may appear in any order — the manifest already fixed precedence. */
@layer utilities {
  /* utilities beats components for the same property (further right). */
  .u-round { border-radius: 999px; }
}

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

@layer components {
  :where(.btn) {
    padding: 0.5rem 1rem;
    border-radius: 4px;   /* vendor's radius loses; components is later */
    background: var(--btn-bg, #0055ff);
    color: #fff;
  }
}

@layer base {
  :root { --btn-bg: #0055ff; }
}

/* To make components win over utilities everywhere, change ONLY the manifest:
   @layer reset, base, vendor, utilities, components, overrides;
   Then .u-round on a .btn would no longer beat a components border-radius. */

With the manifest as written, .btn.u-round renders fully rounded because utilities outranks components. Swap utilities and components in the manifest line — nothing else — and the component’s 4px radius wins instead.


FAQ

Does the @layer statement need to be the very first thing in the file?

It must appear before any rule that references those layers and before any @import that targets them, because layer order is fixed at first registration. It may sit after @charset and after other @layer statements, but a rule block or an @import layer() that mentions a name earlier will register that name first and win the ordering. In practice, put the manifest at the very top so nothing can pre-empt it.

What is the point of declaring an empty layer in the statement?

An empty declaration reserves the layer’s slot in the precedence order before any of its rules exist. This matters when the rules arrive later via @import, from a separate file, or from a lazily loaded chunk — the slot is already fixed, so import timing cannot reshuffle precedence. Without the reservation, the layer would register wherever its rules first happen to load.

Can I change layer precedence just by editing the manifest line?

Yes, provided every layer is pre-registered in that one statement and no rule block or import registers a name earlier. Because the manifest is the single source of first registration, swapping two names in the comma list reorders those layers globally without touching any rule block or selector. Re-run the build and confirm the new order in DevTools.