Skip to main content
>_S

CSS Cascade Layers: From Specificity Wars to @layer Architecture

$ man css-cascade-layers

CSS cascade layers priority stack — @layer architecture diagram

Layer order beats specificity. One declaration line — the entire cascade under control.

The Specificity Tax#

Every CSS codebase that lives long enough eventually collects the same kind of debt: a !important here to override a third-party widget, a nested selector there to win a specificity battle, and eventually a stylesheet where the cascade has become a mystery.

This blog’s CSS was no different. At 1,291 lines, content/assets/css/index.css had:

  • Rules targeting .shiki with !important to override Shiki’s inline styles
  • A flat .cursor class conflicting with the same class in .posts-page
  • .copy-btn, .code-wrapper, .code-statusbar, .prompt, .status-mode, .status-info defined twice — once in the global scope and once inside @scope (.shiki)
  • An :scope block inside @scope (.shiki) duplicating properties already set in the parent pre rule
  • No clear layering strategy — everything competed at the same cascade level

The CSS worked. But it was fragile. Adding a new component meant navigating a minefield of implicit priorities. One wrong selector and you’d trigger a regression.

@layer fixed that.

What Are Cascade Layers?#

Cascade layers let you declare explicit priority order for your style buckets:

@layer reset, base, shiki, components, utilities;NORMALcss │ 1L

This single line — the layer @import-style statement — establishes that any rule in utilities beats any rule in components, which beats shiki, which beats base, which beats reset.

Within the same layer, normal cascade rules apply: specificity, source order, !important all work as they always have.

Across layers, the layer order determines winner — regardless of specificity or source order. A .visually-hidden (a utility-layer rule with specificity 0,1,0) beats a h1 (a base-layer rule with specificity 0,0,1) even if both target the same element, because utilities is declared after base in the layer order.

This is the key insight: layers override specificity.

The Audit#

Before touching any CSS, I mapped every rule group in the file to a conceptual layer:

LayerContentsConflict points
resetbox-sizing: border-boxNone — single rule
baseCustom properties, typography, links, images, code, blockquotes, tables, headings, body chromeDark mode [data-theme="dark"] blocks
shikiShiki syntax highlighting overrides, light-theme color tweaksAll rules use !important — needed isolation
componentsEverything else: .header-anchor, .toc, .layout, .topbar, .theme-toggle, .post-exit, .notice, .home-terminal, .tree-listing, .curl-block, .posts-page, .article-page.cursor collision; .copy-btn/.code-wrapper duplicates
utilities.visually-hiddenNone — single class

The ordering wasn’t arbitrary: components must override shiki (some component rules target .shiki’s children), shiki must override base (Shiki’s !important must beat base typography).

Layer Assignment#

The @layer declaration went right after custom properties, before any unlayered styles:

@layer reset, base, shiki, components, utilities;NORMALcss │ 1L

Then each block wrapped in its layer:

@layer base {
  body { ... }
  h1, h2, h3 { ... }
  /* 168 lines of base styles */
}

@layer shiki {
  .shiki {
    font-variant-ligatures: none;
    background-color: var(--syntax-bg) !important;
  }
  /* 25 lines of highlighted overrides */
}

@layer components {
  .header-anchor { ... }
  .toc { ... }
  /* 892 lines of component styles */
}

@layer utilities {
  .visually-hidden { ... }
}NORMALcss │ 23L

No selector changes. No property value changes. Just wrapping existing blocks in @layer rules.

The !important Isolation#

The biggest win was isolating Shiki’s !important rules. Shiki generates inline styles on every <span>:

<span style="color:#E36209">function</span>NORMALhtml │ 1L

To override these for theming, you need !important. But !important in the global scope can leak into unintended conflicts. Wrapping them in @layer shiki ensures they only win against base and resetcomponents and utilities still override them without needing !important of their own.

@layer shiki {
  .shiki {
    background-color: var(--syntax-bg) !important;
  }
  [data-theme="dark"] .shiki span:not(.status-mode):not(.status-info) {
    color: var(--shiki-dark) !important;
  }
}NORMALcss │ 8L

The !important declarations still exist, but they’re confined to the shiki layer. Any component style can override them naturally — no !important arms race.

Duplicate Elimination#

Before the layer refactor, the flat stylesheet had these duplicates running at the same priority:

/* Global scope — 63 lines */
.copy-btn { ... }
.code-wrapper { ... }
.prompt { ... }
.code-statusbar { ... }
.status-mode { ... }
.status-info { ... }

/* Inside @scope (.shiki) — same selectors, same properties */
@scope (.shiki) {
  & .copy-btn { ... }
  & .code-wrapper { ... }
}NORMALcss │ 13L

I verified every element using these classes on the site is inside .shiki. The @scope version was proven to match the same elements. Result: 63 lines removed, zero visual change.

Similarly, the :scope block inside @scope (.shiki) duplicated the pre box-shadow and .shiki font-variant-ligatures — both already set in @layer base and @layer shiki. Removed.

The .cursor Collision#

.cursor appeared twice in the original stylesheet:

  1. .posts-page .cursor — a blinking block cursor on the posts listing page
  2. .cursor (bare) — a text cursor at the end of each article, inside .post-exit

The bare .cursor was a collision waiting to happen. Both at the same layer, same specificity (0,1,0 vs 0,1,0), but the latter in source-order won — fragile.

The fix was to scope it to .post-exit:

@scope (.post-exit) {
  .cursor {
    color: var(--accent);
    animation: blink 1s step-end infinite;
  }
}NORMALcss │ 6L

Now .posts-page .cursor and .cursor inside .post-exit don’t interact. The @scope boundary prevents the collision entirely — covered in more detail in the @scope post.

What Stays Outside Layers#

Not everything moved into a layer. Three categories stayed unlayered:

1. Global animations:

@keyframes cursor-blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}NORMALcss │ 4L

@keyframes are global references — they don’t participate in the cascade. Wrapping them in a layer is meaningless.

2. Reduced-motion override:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition: none; }
}NORMALcss │ 3L

This must win over every component’s transitions unconditionally. Unlayered styles beat layered styles by default — exactly what we need for accessibility overrides.

3. Desktop and print media queries:

@media (min-width: 768px) { ... }
@media print { ... }NORMALcss │ 2L

These were kept at the bottom of the file, unlayered. They adjust layout chrome and print styles — not specific component behavior that needs layer priority.

This follows the same pattern from Dark Mode Done Right: global overrides stay outside the layer system so they always win.

Results#

MetricBeforeAfter
Total lines1,2911,213
!important declarations8 (scattered)6 (isolated in shiki)
Duplicate rule blocks20
Layers05
Visual regression0 (verified)

The build output confirmed: 87 files, 56 images, 0 errors. Same output, cleaner architecture.

Why @layer Matters in 2026#

@layer hit Baseline in early 2025 and is now supported in every evergreen browser. No polyfill, no build step, no JavaScript.

The real value isn’t theory — it’s the discipline it enforces. When every new CSS rule must be consciously placed in a layer, you think about priority before you write the selector. You reach for !important less because you can reorder layers instead.

Combined with @scope (covered in my previous post), CSS now has a complete architectural toolkit:

  • @layer for cross-category priority
  • @scope for component containment
  • Custom properties for theme and token management
  • light-dark() and color-mix() for dynamic color — covered in From 16 Errors to Zero

No build tools required. Just CSS.

Lessons for Your Own Stylesheet#

If you’re considering a @layer refactor:

  1. Audit before layering. Map every rule group to a conceptual layer. Identify duplicates, !important usage, and collision points first.
  2. Order layers by intent, not specificity. Ask: “when two rules conflict, which should win?” That determines order.
  3. Isolate third-party overrides. Wrapping Shiki’s !important rules in its own layer was the highest-impact change. Same applies for any library or framework CSS you override.
  4. Keep accessibility overrides unlayered. prefers-reduced-motion and forced-colors adjustments must win unconditionally.
  5. Verify with a build. Run your build pipeline after refactoring, compare output. If there’s zero visual regression, you did it right.
/* Start with this: */
@layer reset, base, theme, components, utilities;

@layer reset { /* normalize, box-sizing */ }
@layer base  { /* typography, colors, layout chrome */ }
@layer theme { /* dark mode, high contrast variants */ }
@layer components { /* cards, nav, code blocks, widgets */ }
@layer utilities { /* .visually-hidden, .sr-only, spacing helpers */ }NORMALcss │ 8L

The !important in your stylesheet isn’t a character flaw — it’s a symptom of missing layer architecture.