← Back to Index

Lesson 11

Cascade Layers

CSS specificity was designed for small documents. A single stylesheet, a handful of selectors, and everything just worked. But modern projects have resets, base styles, component libraries, utility classes, and third-party CSS all competing for priority. The result? Specificity wars - developers stacking selectors, adding IDs, or reaching for !important just to make their styles win.

Cascade layers (@layer) solve this by letting you explicitly control which group of styles has priority - regardless of the specificity of individual selectors within those groups. Think of layers as a higher-level priority system that sits above specificity in the cascade.

Quick primer: how specificity is calculated

Before understanding layers, you need a solid grasp of specificity. Every selector has a specificity score expressed as three components: (A, B, C).

Component Counts Example selectors
A - IDs Number of ID selectors #nav = (1,0,0)
B - Classes Classes, attributes, pseudo-classes .btn.primary = (0,2,0)
C - Elements Type selectors, pseudo-elements div p::before = (0,0,3)
/* Specificity: (0, 1, 0) */
.button { background: blue; }

/* Specificity: (0, 2, 0) - wins over .button */
.sidebar .button { background: green; }

/* Specificity: (1, 0, 0) - wins over both */
#submit-btn { background: red; }

/* Specificity: (1, 1, 1) */
div#header .nav-link { ... }

/* The problem: to override .sidebar .button, you need
   equal or higher specificity - leading to escalation */
.main-content .card .button { background: purple; } /* (0, 3, 0) */

The specificity problem: In large codebases, developers keep increasing specificity to override previous styles. Each override requires yet higher specificity, creating an arms race that makes CSS fragile and hard to maintain. Cascade layers break this cycle entirely.

The mental model: cascade resolution order

The cascade resolves conflicting declarations by checking these criteria in order. If a higher criterion produces a winner, lower criteria are never consulted:

  1. Transitions - active transition values always win
  2. !important (with layer inversion) - important declarations from earlier layers beat later ones
  3. Inline styles - the style attribute
  4. Unlayered styles - CSS not inside any @layer
  5. Layers - later declared layers beat earlier ones
  6. Specificity - (A, B, C) comparison
  7. Source order - last rule in document wins

Notice that layers sit above specificity in this hierarchy. A selector with (0,1,0) in a higher-priority layer beats a selector with (1,0,0) in a lower-priority layer. This is the key insight - layer priority trumps specificity entirely.

Declaring and ordering layers

You declare layers with @layer. The order you declare them determines their priority - last declared has highest priority:

/* Declare layer order upfront (recommended) */
@layer reset, base, components, utilities;

/* Now add styles to each layer - order of these blocks doesn't matter */
@layer components {
  .btn { background: blue; padding: 0.5rem 1rem; }
}

@layer utilities {
  .bg-red { background: red; }
}

@layer base {
  body { font-family: system-ui; line-height: 1.6; }
}

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

In the declaration @layer reset, base, components, utilities, priority goes left to right - utilities has the highest priority because it's declared last.

Layer priority demo - the box gets its styles from the highest-priority layer

This box has class "layer-demo-box card". The components layer wins over base because it's declared later.

Declare your layer order at the top of your stylesheet. A single @layer reset, base, components, utilities; statement establishes the priority hierarchy. Then you can add styles to any layer from anywhere in your CSS, and the declared order (not the definition order) determines priority.

Declaration order vs definition order

This is the most critical distinction in cascade layers. There are two separate concepts:

/* Declaration order: A first, B second (B has higher priority) */
@layer A, B;

/* Definition order doesn't matter - even though A's styles
   appear AFTER B's styles in the file, B still wins */
@layer B {
  .box { background: blue; }
}

@layer A {
  .box { background: red; }
}

/* Result: .box is blue (B wins because it was declared later) */

This is what makes layers powerful - you set the priority once at the top, and then you can organise your actual CSS in whatever file/import order makes sense for your project without worrying about accidentally changing priority.

Unlayered styles beat all layers

Any CSS that is not inside an @layer block is considered "unlayered" and has higher priority than all layered styles, regardless of specificity:

@layer utilities {
  /* Even with !important specificity tricks... */
  .special-box { background: gold; }
}

/* This unlayered style WINS over the layered one,
   even though both target .special-box with (0,1,0) specificity */
.special-box { background: purple; }

Unlayered styles override layers - the purple background comes from unlayered CSS

This box has styles in the base layer AND unlayered CSS. The unlayered purple background wins.

Unlayered CSS is at the top of the layer hierarchy. This is by design - it provides an escape hatch. If you need to override layered styles quickly, write the override outside any layer. But in a well-structured project, most CSS should be in a layer, with unlayered styles used sparingly for one-off overrides.

A practical layer architecture

Here's a recommended layer structure for a typical project, from lowest to highest priority:

@layer reset, vendor, base, layout, components, utilities;

/* 1. reset - Normalize browser defaults */
@layer reset {
  *, *::before, *::after { box-sizing: border-box; margin: 0; }
  body { line-height: 1.5; -webkit-font-smoothing: antialiased; }
}

/* 2. vendor - Third-party libraries */
@layer vendor {
  /* Imported via @import url("...") layer(vendor); */
}

/* 3. base - Typography, colours, design tokens */
@layer base {
  :root { --accent: #2563eb; --text: #1a1a2e; }
  body { font-family: system-ui, sans-serif; color: var(--text); }
  a { color: var(--accent); }
}

/* 4. layout - Page-level structure */
@layer layout {
  .container { max-width: 1200px; margin: 0 auto; padding: 0 1rem; }
  .grid { display: grid; gap: 1rem; }
}

/* 5. components - UI components */
@layer components {
  .btn { padding: 0.5rem 1rem; border-radius: 4px; }
  .card { border: 1px solid var(--border); border-radius: 8px; }
}

/* 6. utilities - Single-purpose overrides (highest priority) */
@layer utilities {
  .hidden { display: none; }
  .text-center { text-align: center; }
  .mt-4 { margin-top: 1rem; }
}

This architecture mirrors how specificity should work logically: utilities override components, components override base styles, and base styles override resets. With layers, this works regardless of selector specificity - a utility class .hidden with specificity (0,1,0) reliably overrides a component selector .modal.active .panel with specificity (0,3,0).

!important and layers - the inversion

Here's where layers get surprising. For normal (non-important) declarations, later layers win. But for !important declarations, the priority inverts - earlier layers win:

Priority (highest first) Normal declarations !important declarations
1st Unlayered First-declared layer (e.g., reset)
2nd Last-declared layer (e.g., utilities) Second-declared layer (e.g., base)
3rd ... ...
Last First-declared layer (e.g., reset) Unlayered
@layer reset, base, components, utilities;

@layer reset {
  /* !important here has HIGHEST priority among layered !important */
  img { display: block !important; }
}

@layer utilities {
  /* Normal: utilities wins (last declared) */
  .block { display: block; }

  /* !important: utilities loses to reset's !important (first declared) */
  .inline { display: inline !important; }
}

/* Why this makes sense: !important in a reset means "this must
   NEVER be overridden" - and the inversion guarantees exactly that. */

Why the inversion exists: !important was always meant to be a "this must not change" signal. The inversion makes that intent reliable. A reset layer saying img { display: block !important; } truly cannot be overridden by later layers - because the first-declared layer has highest !important priority. This finally gives !important a sensible, predictable role.

Ad

Adding third-party CSS to a layer

You can import external stylesheets directly into a named layer using @import with the layer() function:

/* Import third-party CSS into a dedicated layer */
@import url("https://cdn.example.com/library.css") layer(vendor);

/* Or import into a new anonymous layer */
@import url("normalize.css") layer;

/* Layer order still determined by your declaration */
@layer reset, vendor, base, components, utilities;

/* Now the third-party CSS has lower priority than your components
   and utilities - no more fighting their selectors! */

This is transformative for managing third-party CSS. Instead of fighting specificity battles with Bootstrap, Tailwind preflight, or any other library, you simply put it in a layer below your own code. Your styles always win without needing to increase specificity.

/* Real-world example: taming a UI library */
@layer reset, vendor, theme, components, utilities;

@import url("open-props/style.css") layer(vendor);
@import url("some-component-lib/styles.css") layer(vendor);

@layer theme {
  /* Your design tokens override the library's defaults
     without needing higher specificity */
  :root {
    --brand: #2563eb;
    --radius: 8px;
  }
}

@layer components {
  /* Your component styles always beat the vendor layer,
     even if the library uses .namespace .component .element */
  .btn {
    background: var(--brand);
    border-radius: var(--radius);
  }
}

Nested layers

Layers can be nested inside other layers using dot notation or block nesting. Nested layers are scoped to their parent:

/* Dot notation for declaring nested layers */
@layer components.buttons, components.cards, components.forms;

/* Block nesting syntax */
@layer components {
  @layer buttons {
    .btn { padding: 0.5rem 1rem; }
  }
  @layer cards {
    .card { border-radius: 8px; }
  }
}

/* Equivalent dot notation for adding styles */
@layer components.buttons {
  .btn-primary { background: blue; }
}

/* Priority: within the components layer,
   forms > cards > buttons (last declared wins) */

Nested layers are useful for organising large layers. A components layer might contain sub-layers for different component categories. The parent layer's position in the top-level hierarchy is unchanged - nesting only affects priority within that parent.

Anonymous layers

You can create layers without naming them. Anonymous layers are useful when you want to isolate styles at a lower priority without tracking a name:

/* Anonymous layer - cannot be referenced later */
@layer {
  .legacy-widget { color: grey; font-size: 14px; }
}

/* Another anonymous layer - each is separate */
@layer {
  .old-component { margin: 10px; }
}

/* Anonymous layers are ordered by their position in the source.
   You can't add more styles to them later because they have no name.
   Useful for one-shot isolation of legacy CSS. */

When to use anonymous layers: They're ideal for wrapping legacy CSS you don't control and don't need to extend. Since they can't be referenced by name, they act as sealed containers. Named layers are better for anything you'll actively maintain or add to over time.

How this changes your workflow

Here's a before/after comparison of how you handle specificity conflicts:

Scenario Before (no layers) After (with layers)
Utility class not applying Add !important or increase specificity Utilities layer already wins - just works
Third-party styles overriding yours Match or exceed their specificity Put vendor CSS in a lower layer
Reset styles leaking into components More specific selectors on components Reset in lowest layer, components always win
File/import order changing priority Careful ordering of stylesheets Declaration order is explicit and separate from file order
Collaborator writes high-specificity CSS Cascade breakage, debugging pain Their layer determines priority, not their selectors

The fundamental shift: instead of managing specificity at the selector level (which is fragile and doesn't scale), you manage priority at the architectural level. Each category of CSS has a defined priority, and individual selectors within that category compete only with each other.

Browser support

Cascade layers (@layer) are supported in all modern browsers: Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+. That covers over 95% of global users as of 2024. For older browsers, unlayered CSS is simply treated normally - it doesn't break, it just doesn't get the layer prioritisation benefits.

/* Feature detection if needed */
@supports at-rule(@layer) {
  /* Layer-aware styles */
}

/* In practice, you rarely need this.
   Browsers that don't support @layer simply ignore the layer
   declarations and treat all styles as unlayered (normal behaviour). */

Retrieval check

Question 1

Where do layers sit relative to specificity in the cascade resolution order?

Below - specificity is checked before layers
Above - layer priority is checked first
Same level - they're combined into one score
It depends on whether !important is used

Question 2

Given @layer reset, base, components, utilities - which layer has the highest normal priority?

reset - first declared gets priority
components - it has the most specific selectors
utilities - last declared wins
They all have equal priority

Question 3

What beats all layered styles in the cascade?

Styles with higher specificity selectors
Styles using !important
Unlayered styles (CSS not inside any @layer)
Inline styles only

Question 4

For !important rules, how is layer priority resolved?

Same as normal - last declared wins
Inverted - first declared layer wins
!important ignores layers entirely
All !important rules have equal priority

Question 5

How do you put third-party CSS into a named layer?

@layer vendor { @include "library.css"; }
link rel="stylesheet" layer="vendor" href="library.css"
@import url("library.css") layer(vendor)
@layer(vendor) url("library.css")