The Cascade Reclaimed
A manifesto for web styling in the age of modern CSS
We have spent fifteen years running from the cascade. First with naming conventions – BEM, SMACSS, OOCSS – bolting structure onto a language that didn’t enforce it. Then with build tools – CSS Modules, styled-components, Emotion – abandoning stylesheets entirely in favour of runtime JavaScript. And finally with Tailwind, which solved the collision problem by nuking the abstraction layer altogether, scattering flex items-center justify-between px-4 py-2 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 across every <div> in the codebase and calling it progress.
Every one of these was a rational response to a real problem. The cascade was dangerous at scale. Naming was fragile. Global stylesheets did break silently when teams grew. But every solution also threw away something valuable – separation of concerns, central design authority, semantic markup, small footprints, or all four at once.
It is 2026. CSS has quietly become a different language. Not through a single headline feature, but through the accumulation of capabilities that, taken together, change what is structurally possible:
@layergives you explicit control over cascade priority, making specificity wars a solved problem.@scopecontains styles to a DOM subtree without any naming convention, build tool, or runtime.@propertylets you register typed, inheritable custom properties with defaults – turning the cascade into a contextual type system.:has()lets CSS inspect the contents and relationships of elements, eliminating most presentational classes.- Native nesting removes the last major reason anyone reached for a preprocessor.
- Container queries let components respond to their own size rather than the viewport.
@starting-styleand theallow-discretetransition behaviour let you animate elements appearing and disappearing – no JS, no libraries.- Styleable form elements (
<select>,<input>,<details>) are finally arriving, killing one of the oldest reasons to reach for a component library.
None of these existed in practical cross-browser form five years ago. Most shipped in the last two or three. Collectively, they make a new paradigm possible – one that reclaims everything we gave up without reintroducing the problems that drove us away.
This document describes that paradigm. It is opinionated. It is practical. And it is not a framework – it is a set of architectural principles that produce clean markup, collision-proof styles, central design authority, and a CSS footprint measured in kilobytes.
The five principles
-
The markup is the source of truth. HTML should be semantic and nearly class-free. If you are adding a class purely to give CSS something to grab onto, that is a smell. Modern CSS can select on structure, content, attributes, and relationships. Let it.
-
The cascade is an asset, not a liability. Layers make cascade priority explicit. Scopes make it bounded. Typed properties make it a contextual inheritance system. Stop fighting the cascade and start programming it.
-
Design decisions are centralised. Every colour, spacing value, radius, and typographic choice flows from a single set of design tokens. Components consume tokens; they never hardcode values. A change at the token level propagates everywhere, including through utilities.
-
Collisions are structurally impossible. Not prevented by discipline. Not prevented by naming. Prevented by the platform:
@layerordering resolves before specificity, and@scopeboundaries prevent selectors from leaking. This is enforcement, not convention. -
Utility classes are for composition gaps, not for components. A thin layer of token-derived utilities – spacing, colour, visibility – handles the seams between composed blocks. If you are stacking more than two utilities on one element, extract a semantic rule. The utility layer is an escape valve, not an architecture.
Part I: The layer stack
Everything begins with an explicit layer order declared once, at the top of your root stylesheet:
@layer reset, tokens, layout, components, utilities, overrides;
This single line eliminates the entire category of “my styles are being overridden and I don’t know why.” Layers resolve in declared order regardless of specificity. A selector in components will never beat a selector in utilities, and a selector in utilities will never beat a selector in overrides – even if the component selector is #app .sidebar nav > ul > li > a:first-child and the override selector is .mt-l.
No !important. No specificity hacks. No re-ordering your <link> tags and praying.
Each layer has a clear purpose:
reset – Normalise browser defaults. This is your Meyer reset, your box-sizing: border-box, your margin zeroing. Written once, touched never.
tokens – Design primitives. Colours, spacing scale, type scale, radii, shadows, motion timing. These are registered custom properties living on :root (or contextual overrides on [data-theme], [data-density], etc.). This is the single source of design truth.
layout – Page-level structure. Your grid shells, your sticky headers, your sidebar-plus-content frames. These concern where things go, not what things look like.
components – The visual identity of discrete UI elements. Cards, buttons, form fields, nav bars, dialogs. Each scoped to its own DOM subtree.
utilities – A deliberately thin set of single-purpose helpers for composition gaps. Margins between siblings, text alignment in a one-off context, visually-hidden content. Never more than 40–60 rules total.
overrides – The “break glass in emergency” layer. Page-specific tweaks, third-party widget fixes, temporary hacks with a /* TODO: remove after redesign */ comment. By living in the highest layer, these always win without needing !important, and they are easy to find and audit.
What this solves
- Specificity wars: Gone. Layer order is the first (and usually only) resolution mechanism.
- Ordering fragility: Gone. It does not matter which
<link>tag comes first as long as everything is assigned to its layer. !importantescalation: Gone. The override layer exists precisely for this purpose, with no need for specificity nuclear options.- “Where does this rule go?”: Answered by the layer name. A new developer can look at the layer declaration and understand the architecture in ten seconds.
Part II: Scoped components
Within the components layer, every component is wrapped in a @scope block:
@layer components {
@scope (.card) {
:scope {
background: var(--surface);
color: var(--on-surface);
border-radius: var(--radius-m);
padding: var(--space-m);
container-type: inline-size;
}
.title {
font-size: var(--text-lg);
font-weight: 600;
line-height: 1.25;
}
.body {
line-height: 1.6;
}
.actions {
display: flex;
gap: var(--space-s);
justify-content: flex-end;
}
}
@scope (.badge) {
:scope {
font-size: var(--text-xs);
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-full);
background: var(--color-accent);
color: var(--on-accent);
}
}
}
Notice: .title inside .card and .title inside any other component are completely separate selectors. @scope ensures that the rules inside @scope (.card) only match elements that are descendants of .card. There is no collision, no leaking, no need for .card__title or data-card-title or any other workaround.
You can use short, readable class names – .title, .body, .actions, .icon – across your entire codebase without fear. This is what BEM was trying to achieve, but enforced by the browser rather than by human discipline.
Scoping boundaries with to
@scope also supports a lower boundary, which limits how deep the scope reaches. This prevents a component from styling the internals of a nested child component:
@scope (.card) to (.card, .badge, .dialog) {
/* These rules will NOT penetrate into nested .card,
.badge, or .dialog subtrees */
}
This is the compositional boundary that CSS Modules and Shadow DOM tried to provide, but without the tooling overhead of the former or the harsh isolation of the latter. Scoped styles participate in the cascade normally – they inherit, they can be overridden by higher layers – they just can’t reach into components that don’t belong to them.
The case for @scope over class-prefix conventions
You might be tempted to achieve similar isolation with .card-title instead of @scope (.card) { .title {} }. The difference is structural:
- If someone writes
.card-titleinside a nested component, it will match.@scopewill not. - If someone accidentally omits the prefix, it silently becomes a global rule.
@scopeselectors are local by default. - BEM requires every developer to remember and apply the convention. Scope is enforced by the parser.
Conventions are not architecture. @scope is architecture.
Part III: Typed design tokens
Design tokens in most systems are untyped custom properties: --color-primary: #3b82f6. This works, but it has limitations – you cannot transition them, there is no validation, and nothing stops someone from writing padding: var(--color-primary) and getting a silent failure.
@property fixes this:
@property --surface {
syntax: "<color>";
inherits: true;
initial-value: #ffffff;
}
@property --on-surface {
syntax: "<color>";
inherits: true;
initial-value: #1a1a1a;
}
@property --space-unit {
syntax: "<length>";
inherits: true;
initial-value: 0.25rem;
}
@property --density {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@property --radius-m {
syntax: "<length>";
inherits: false;
initial-value: 0.375rem;
}
Why this matters
Type safety. A <color> property will reject a length value. A <number> property will reject a string. The browser enforces your design system’s type contracts.
Animatability. Unregistered custom properties cannot be transitioned – the browser does not know their type and so cannot interpolate. Registered properties with syntax: "<color>" or syntax: "<length>" can be smoothly transitioned and animated. Theme switches can fade. Density changes can ease. No JavaScript.
Explicit inheritance control. Some tokens should inherit (colours, density, typography), and some should not (border-radius, shadow depth). @property lets you declare this explicitly, preventing surprising inheritance chains.
Initial values as documentation. The initial-value serves as both a fallback and a declaration of the design system’s default. If a component references var(--surface) and nobody has set it, it gets white. Not unset. Not an empty string. A typed, intentional default.
Contextual theming through inheritance
Because registered properties participate in the cascade’s inheritance, theming becomes DOM-structural rather than imperative:
@layer tokens {
[data-theme="dark"] {
--surface: #1a1a1a;
--on-surface: #e5e5e5;
--surface-raised: #2a2a2a;
}
[data-density="compact"] {
--density: 0.65;
}
[data-density="spacious"] {
--density: 1.4;
}
}
Now wrap any subtree in <section data-theme="dark"> and every component inside it – buttons, cards, inputs, badges – adapts automatically. No theme prop drilling. No context providers. No runtime JS. The cascade does what it has always done: it passes values down the tree. You are just giving it better values to pass.
A component written once works in light mode, dark mode, compact density, spacious density, and any combination thereof – without knowing any of these contexts exist. This is the separation of concerns that inline styles (and Tailwind) fundamentally cannot achieve.
The token scale
Define your spacing scale as derivations from a base unit:
@layer tokens {
:root {
--space-unit: 0.25rem;
--space-xs: calc(var(--space-unit) * 1); /* 0.25rem */
--space-s: calc(var(--space-unit) * 2); /* 0.5rem */
--space-m: calc(var(--space-unit) * 4); /* 1rem */
--space-l: calc(var(--space-unit) * 6); /* 1.5rem */
--space-xl: calc(var(--space-unit) * 8); /* 2rem */
--space-2xl: calc(var(--space-unit) * 12); /* 3rem */
}
}
Every spacing value in the system derives from --space-unit. Multiplied by --density, you can scale the entire spatial system:
button {
padding: calc(var(--space-s) * var(--density))
calc(var(--space-m) * var(--density));
}
One number – --density – controls how tight or loose every component feels. One number – --space-unit – controls the underlying spatial grid. Central authority. Zero duplication. Maximum leverage.
Part IV: Content-relational styling
This is the most radical departure from how we have written CSS for the last twenty years. The premise: most classes exist to tell CSS something CSS can now figure out for itself.
:has() is a relational pseudo-class. It lets you select an element based on what it contains. Combined with structural pseudo-classes, attribute selectors, and native nesting, you can write CSS that responds to the shape of the DOM – not to manually applied labels.
The semantic article
<article>
<img src="hero.jpg" alt="A coastal sunset">
<h2>The Quiet Coast</h2>
<p>Long-form body text here.</p>
<footer>
<time datetime="2026-03-15">March 15, 2026</time>
<span>8 min read</span>
</footer>
</article>
@layer components {
@scope (article) {
:scope {
padding: var(--space-m);
background: var(--surface);
}
/* Article with a hero image: grid layout */
:scope:has(> img:first-child) {
display: grid;
grid-template-rows: 200px 1fr auto;
> img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--radius-m) var(--radius-m) 0 0;
}
}
/* Article without an image: accent border */
:scope:not(:has(> img)) {
border-left: 3px solid var(--color-accent);
}
/* Article containing a blockquote: pull-quote style */
:scope:has(blockquote) {
font-style: italic;
background: var(--surface-muted);
}
/* Article followed by another article: tighter margin */
:scope:has(+ article) {
margin-bottom: var(--space-s);
}
}
}
The HTML contains no presentational classes. No .article--with-hero. No .article--text-only. No .article--quote-variant. The CSS reads the DOM structure and adapts. Add an image, the layout changes. Remove it, the border appears. The markup stays clean, semantic, and concerned only with content.
Relational form styling
<form>
<label>
Email
<input type="email" required>
</label>
<label>
Message
<textarea required></textarea>
</label>
<button type="submit">Send</button>
</form>
@layer components {
@scope (form) {
:scope:has(:invalid) button[type="submit"] {
opacity: 0.5;
cursor: not-allowed;
}
label:has(:focus-visible) {
color: var(--color-accent);
}
label:has(:invalid:not(:placeholder-shown)) {
color: var(--color-danger);
}
label:has(:valid) {
color: var(--color-success);
}
}
}
No JavaScript. No form libraries. No state management. The submit button dims when any required field is invalid. Labels change colour based on validation state. All driven by the DOM state that the browser already tracks.
When to still use classes and attributes
Content-relational styling handles structural variations. But some distinctions are truly semantic – the difference between a primary and a destructive button, for instance, is not structural. For those, use data attributes:
<button data-variant="primary">Save</button>
<button data-variant="danger">Delete</button>
@scope (button) {
:scope {
padding: var(--space-s) var(--space-m);
border-radius: var(--radius-m);
font-weight: 500;
}
:scope[data-variant="primary"] {
background: var(--color-primary);
color: var(--on-primary);
}
:scope[data-variant="danger"] {
background: var(--color-danger);
color: var(--on-danger);
}
}
Why data-variant rather than .btn-primary? Because data attributes carry semantic meaning – they describe what something is, not what it should look like. And because they are scoped by the @scope block, they cannot collide with data attributes on unrelated components. This is a deliberate choice: classes for scoping boundaries, data attributes for semantic variations, and :has() for structural variations. Each selection mechanism has a clear, non-overlapping purpose.
Part V: The utility layer
Utilities are not the architecture. They are the mortar between bricks.
The utility layer is thin – 40 to 60 rules at most – generated directly from your design tokens. It covers three domains: spacing, colour, and visibility.
@layer utilities {
/* Spacing -- derived from token scale */
.mt-xs { margin-top: var(--space-xs); }
.mt-s { margin-top: var(--space-s); }
.mt-m { margin-top: var(--space-m); }
.mt-l { margin-top: var(--space-l); }
.mt-xl { margin-top: var(--space-xl); }
.mb-xs { margin-bottom: var(--space-xs); }
.mb-s { margin-bottom: var(--space-s); }
.mb-m { margin-bottom: var(--space-m); }
.mb-l { margin-bottom: var(--space-l); }
.mb-xl { margin-bottom: var(--space-xl); }
.gap-s { gap: var(--space-s); }
.gap-m { gap: var(--space-m); }
.gap-l { gap: var(--space-l); }
/* Colour -- text overrides */
.text-muted { color: var(--color-muted); }
.text-accent { color: var(--color-accent); }
.text-danger { color: var(--color-danger); }
.text-success { color: var(--color-success); }
/* Typography */
.text-center { text-align: center; }
.text-sm { font-size: var(--text-sm); }
.text-lg { font-size: var(--text-lg); }
.font-bold { font-weight: 700; }
/* Display and visibility */
.visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
width: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
}
That is the entire utility layer. Fifty-odd rules. Compare this with Tailwind’s thousands.
The discipline
Utilities are for composition gaps – the space between components that no single component should own:
<section>
<article>...</article>
<article class="mt-l">...</article> <!-- gap between siblings -->
</section>
<footer class="mt-xl text-center text-muted">
<p>© 2026 Acme Corp</p>
</footer>
If you find yourself writing class="flex items-center gap-s text-sm text-muted font-bold", you have built a component out of utilities. Stop. Extract it into a scoped component rule.
The two-utility rule: if an element needs more than two utilities, it likely deserves a semantic style. This is not a hard limit – it is a code smell threshold.
Because the utilities reference design tokens, a central change to --space-m propagates through both component styles and utilities. Central authority is preserved even in the escape hatch.
Part VI: Container queries and intrinsic layout
Media queries ask “how wide is the viewport?” Container queries ask “how wide is the component?” This distinction matters enormously in component-based design, where the same card might appear in a narrow sidebar and a wide main content area within the same viewport.
@layer components {
@scope (.card) {
:scope {
container-type: inline-size;
/* ... base styles ... */
}
@container (min-width: 400px) {
:scope {
display: grid;
grid-template-columns: 200px 1fr;
}
}
@container (min-width: 700px) {
:scope {
grid-template-columns: 300px 1fr auto;
}
.actions {
flex-direction: column;
align-self: start;
}
}
}
}
The card adapts to its own available space, not the screen. This is intrinsic design – the component carries its own layout logic. No breakpoint props. No size-variant classes. No JavaScript resize observers.
Combined with @scope and :has(), you reach a level of component autonomy that previously required a JavaScript framework. The component knows its boundaries (scope), its content (:has()), and its available space (container queries). All in CSS. All without a build step.
Part VII: Motion and transitions
CSS can now handle most of the transitions that used to require JavaScript animation libraries.
Entry and exit animations with @starting-style
dialog[open] {
opacity: 1;
transform: translateY(0);
transition: opacity 200ms ease, transform 200ms ease,
display 200ms allow-discrete;
@starting-style {
opacity: 0;
transform: translateY(-1rem);
}
}
dialog:not([open]) {
opacity: 0;
transform: translateY(-1rem);
transition: opacity 150ms ease, transform 150ms ease,
display 150ms allow-discrete;
}
Dialogs, popovers, dropdown menus, toast notifications – all can animate in and out with CSS alone, using the native HTML elements and attributes. No Framer Motion. No GSAP. No useTransition hook.
The allow-discrete value lets you transition display: none to display: block, which was impossible until recently. Elements can now truly appear and disappear with fluid motion, not just opacity tricks over invisible-but-still-present DOM nodes.
Token-driven motion
Centralise your timing in the token layer:
@layer tokens {
:root {
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 400ms;
}
@media (prefers-reduced-motion: reduce) {
:root {
--duration-fast: 0ms;
--duration-normal: 0ms;
--duration-slow: 0ms;
}
}
}
By zeroing all durations under prefers-reduced-motion, every transition in your entire system respects the user’s motion preference – automatically, centrally, without any per-component logic.
Putting it all together
Here is a minimal but complete example showing all principles working in concert.
tokens.css
@layer reset, tokens, layout, components, utilities, overrides;
@layer tokens {
@property --surface {
syntax: "<color>";
inherits: true;
initial-value: #ffffff;
}
@property --on-surface {
syntax: "<color>";
inherits: true;
initial-value: #1a1a1a;
}
@property --color-accent {
syntax: "<color>";
inherits: true;
initial-value: #3b82f6;
}
@property --density {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
:root {
--space-unit: 0.25rem;
--space-xs: calc(var(--space-unit) * 1);
--space-s: calc(var(--space-unit) * 2);
--space-m: calc(var(--space-unit) * 4);
--space-l: calc(var(--space-unit) * 6);
--space-xl: calc(var(--space-unit) * 8);
--radius-m: 0.375rem;
--radius-full: 9999px;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.25rem;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--duration-normal: 200ms;
}
[data-theme="dark"] {
--surface: #1a1a1a;
--on-surface: #e5e5e5;
}
[data-density="compact"] {
--density: 0.65;
}
@media (prefers-reduced-motion: reduce) {
:root {
--duration-normal: 0ms;
}
}
}
components/card.css
@layer components {
@scope (.card) to (.card) {
:scope {
background: var(--surface);
color: var(--on-surface);
border-radius: var(--radius-m);
padding: calc(var(--space-m) * var(--density));
container-type: inline-size;
transition: box-shadow var(--duration-normal) var(--ease-out);
}
:scope:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
}
:scope:has(> img:first-child) {
padding: 0;
> img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius-m) var(--radius-m) 0 0;
}
> :not(img) {
padding-inline: calc(var(--space-m) * var(--density));
}
> :last-child {
padding-bottom: calc(var(--space-m) * var(--density));
}
}
.title {
font-size: var(--text-lg);
font-weight: 600;
}
.meta {
font-size: var(--text-sm);
color: color-mix(in srgb, var(--on-surface) 60%, transparent);
}
@container (min-width: 500px) {
:scope:has(> img:first-child) {
display: grid;
grid-template-columns: 200px 1fr;
> img {
height: 100%;
border-radius: var(--radius-m) 0 0 var(--radius-m);
}
}
}
}
}
The HTML
<main>
<section data-theme="dark">
<article class="card">
<img src="sunset.jpg" alt="Coastal sunset">
<h2 class="title">The Quiet Coast</h2>
<p>Body text...</p>
<p class="meta">March 15, 2026 · 8 min read</p>
</article>
</section>
<aside data-density="compact">
<article class="card">
<h2 class="title">No Image Here</h2>
<p>This card has no hero image, so it gets different styling.</p>
</article>
</aside>
</main>
Count the classes: card, title, meta. Three. The card adapts to dark mode, compact density, presence or absence of images, and its own container width – all from CSS. The markup is readable. The styles are centralised. Collisions are impossible.
What we stopped needing
| Problem | Old solution | This paradigm |
|---|---|---|
| Cascade collisions | BEM, CSS Modules, Tailwind | @layer + @scope |
| Naming conventions | .block__element--modifier |
Short names inside @scope |
| Theming | JS context providers, class toggling | @property inheritance |
| Component variants | Presentational classes | :has() + data-* attributes |
| Responsive components | Viewport media queries + JS | Container queries |
| Preprocessor variables | SASS/LESS $variables |
Native custom properties |
| Preprocessor nesting | SASS/LESS nesting | Native nesting |
| Entry/exit animation | JS animation libraries | @starting-style + allow-discrete |
!important wars |
Rage | @layer overrides |
| Build step | Required | Optional |
What this is not
This is not a framework. There is no npm install. There is no config file. There is no build step (though you can add one if you want file splitting or minification – that is your choice, not a requirement).
This is an architectural pattern. It tells you how to organise your CSS and what tools to reach for in each situation. The implementation is vanilla CSS. The browser is the runtime.
It does not ask you to unlearn CSS. It asks you to use the CSS that actually exists today, instead of the CSS you learned to work around in 2015.
What it asks of you
Stop adding classes reflexively. Before writing class="...", ask: can :has(), a structural pseudo-class, or a data attribute handle this?
Stop fearing the cascade. You control it now. Layers are your priority system. Scopes are your boundaries. Properties are your type system. The cascade is not your enemy – it is your most powerful tool.
Stop reaching for JavaScript to style things. Theme providers, resize observers, animation libraries, conditional class logic – most of these exist because CSS could not do the job. It can now.
Accept the small imperfections. There will be edge cases where you need a utility class, or a manual class, or an override. That is fine. This is not about purity. It is about having a default posture that produces clean, scalable, maintainable CSS – and reaching for escape hatches only when you genuinely need them.
The cascade was never the problem. Our inability to control it was. That problem is solved.