DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

CSS Cascade Layers Finally Made My Design System Predictable

  • CSS Cascade Layers (@layer) let you control the order of style precedence without specificity wars or !important tags

  • My design system has three layers in a strict order: reset, tokens, components, utilities, and overrides

  • Third-party CSS goes into a low-priority layer so my own styles always win without me writing more selectors

  • Tailwind v4 and most modern frameworks ship with @layer baked in, you just have to use it correctly

  • The whole thing is supported in every browser since 2022 and I should have adopted it two years ago

I spent four years writing CSS like it was 2017. Specificity hacks, !important bombs, a utility class called !important-please-work, the usual stack. Then I rebuilt my design system around CSS Cascade Layers last month and realized I had been making my own life harder for no reason.

@layer is not new. It shipped in every major browser in 2022. But it is one of those features where the docs make it sound like a niche tool for framework authors, when actually it is the single best thing that has happened to CSS architecture since Flexbox. If you maintain a design system or ship any reusable components, you are leaving clarity and maintainability on the table by not using it.

Here is what changed when I moved my whole design system into explicit layers, what broke along the way, and how I structure things now.

The Problem @layer Solves

Every design system eventually hits the same wall. You have base styles, component styles, utilities, and user overrides. They all need to coexist, and the order of precedence matters. The traditional answer is specificity: more selectors and !important tags win. The modern answer is stacking contexts and source order. Both are fragile.

Specificity wars look like this. You write .button { background: blue; } in your component file. Someone else writes body .button { background: red; } in a page-specific stylesheet. Red wins because body adds specificity. You add .theme-dark .button { background: blue; } to fight back. Someone else adds html body .button { background: green; }. Three months later you are five selectors deep and nobody can remember which one is actually applying.

Source order is the other answer. Whichever stylesheet loads last wins at the same specificity. This works until you have a CDN-loaded third-party component that comes in after your bundle, or until a utility framework like Tailwind needs to beat your component styles, or until you load a print stylesheet and now nothing works on paper.

@layer fixes both problems by making precedence explicit and independent of selector specificity or source order. You declare layers in the order you want them to apply, and styles inside those layers respect the layer order before they check specificity.

The mental model is stackable transparencies. Each layer is a sheet. Higher layers beat lower layers regardless of what is drawn on them.

My Five-Layer Stack

Every design system project I ship uses the same five layers in the same order.


@layer reset, tokens, base, components, utilities, overrides;

Enter fullscreen mode Exit fullscreen mode

The declaration order matters. Reset is lowest priority. Overrides is highest. Anything that is not in a layer, called "unlayered styles", has higher priority than anything in any layer. That is useful and also dangerous, which I will get to.

Reset layer. Normalize.css or my own minimal reset. All browser default overrides. This should never win against anything intentional.

Tokens layer. CSS custom properties for colors, spacing, typography, radius. This layer does not usually compete for specificity because tokens are variables, but I put them here for consistency and so I can override them per theme.

Base layer. Raw element styles. What does h1 look like by default. What does a plain p look like. If a component does not override these, the base is what you get.

Components layer. The bulk of my design system. Button, Card, Input, Modal. Each component owns its own styles in this layer. Components can reference tokens from the tokens layer. Inside the components layer, normal specificity rules apply, so a .button-primary beats a .button.

Utilities layer. The Tailwind-style single-purpose classes. Margin, padding, display, color utilities. These beat component styles because utilities should override by design. If I add mt-4 to a button, I mean for that button to have 16 pixels of top margin, full stop.

Overrides layer. The escape hatch. If I genuinely need to override something at the last moment for a specific page or context, it goes here. In practice this layer is almost empty. Its existence alone solves 90 percent of the problem.

How Tailwind v4 Changed This

Tailwind v3 built its own precedence system on top of CSS source order and lots of internal tricks. It worked, mostly, but you had to know which of Tailwind's internal layers to add your custom CSS to and in what order to load things.

Tailwind v4 ships with native @layer baked in. The defaults are theme, base, components, utilities. Your own layers sit alongside those, and the order is something you control in your main CSS file.


@layer reset, theme, base, tokens, components, utilities, overrides;

@import "tailwindcss";
@import "./reset.css" layer(reset);
@import "./tokens.css" layer(tokens);
@import "./components.css" layer(components);
@import "./overrides.css" layer(overrides);

Enter fullscreen mode Exit fullscreen mode

The layer() on the @import is the syntax that pushes an entire imported stylesheet into a named layer. Before this existed you had to wrap every rule in the file with @layer component { ... } manually, which nobody did.

The behavior change I care about most. When I write Click and my component stylesheet says .button { background: var(--color-primary); }, the utility wins because utilities is declared later than components. No !important, no specificity bomb, just ordered layers doing their job.

The Gotchas Nobody Warns You About

Three things I wish I had known before migrating.

Unlayered styles are top priority. Anything not wrapped in a @layer has higher priority than anything inside any layer. This is deliberate, because it lets you keep one-off styles outside the system. It also means if you forget to put a stylesheet in a layer, it silently wins against everything. My fix: every CSS file in the project must be inside a layer, enforced by a linter rule. If a rule needs to escape the layer system, it goes in the explicit overrides layer, not in unlayered limbo.

!important is evaluated per layer. This is backwards from normal CSS. Inside a layer, !important still wins. But !important in a lower layer loses to normal rules in a higher layer. This is correct behavior but it confuses everyone on first encounter. The practical impact: you can safely !important a utility class and know it will not steamroll your override layer.

Inheritance happens inside layers. A layer does not block cascade inheritance. If your tokens layer defines body { color: var(--text-primary); }, that color inherits into components in the components layer, same as normal. Layers only affect which rule wins when multiple rules target the same property on the same element.

Dev tools support is partial. Chrome DevTools shows layer information in the Styles panel now. Safari and Firefox are catching up but still a bit behind. If your layer ordering is wrong, you can see it in Chrome. In the other browsers you might have to reason from first principles.

The Third-Party CSS Escape Hatch

Here is the killer feature I did not expect. You can put third-party CSS into its own layer and control whether it beats your own styles without editing the vendor code.


@import "bootstrap/dist/css/bootstrap.css" layer(vendor);
@layer vendor, reset, tokens, components, utilities;

Enter fullscreen mode Exit fullscreen mode

Vendor is declared first, so it has the lowest priority. Now when I use a Bootstrap component, it provides the baseline styles, and my own styles beat it for free. No specificity boosting, no !important. The third-party library becomes a foundation I can freely override, not a boss fight.

This works for anything. Bootstrap, Bulma, UIKit, a CMS theme I inherited, random CSS my analytics tool injects. All of it can go into a low-priority layer and stop fighting my design system.

The reverse also works. I have a customer-facing library I ship to other shops. It imports into their site as layer(raxxo-components). If they want to override any of my styles, they just put their own CSS in a higher layer and it works. No CSS variable gymnastics, no prop-drilling. Pure cascade.

Bottom Line

Cascade Layers replaced four years of ad-hoc specificity hacks in my design system with five lines of declaration and a clear mental model. The whole migration took a weekend for a medium-sized codebase. The result is cleaner, shorter selectors, zero !important tags, and third-party CSS that actually behaves.

If you maintain any CSS architecture longer than one page, move to @layer this week. The browser support has been solid since 2022. Tailwind v4, Open Props, and most modern design systems already ship with it. You just have to stop writing pre-2022 CSS inside a 2026 framework.

The mental model flip is the hard part. Stop thinking in selectors and specificity. Start thinking in ordered layers. Once it clicks, you will not go back.

Top comments (0)