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 favor 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 unwieldy at scale. Naming was fragile. Global stylesheets did break silently when teams grew. Each layer of workaround was a cast on a bone that was genuinely fractured — and at the time, you were right to put one on.
The bone healed years ago. The casts are still on. The limb underneath has begun to atrophy.
What healed it is not a single feature. It is a quiet evolution. @layer gives you explicit control over cascade priority — specificity wars, solved. @scope contains styles to a DOM subtree without any naming convention, build tool, or runtime — collisions, structurally impossible. @property lets you register typed, inheritable custom properties with defaults, turning the cascade into something close to a contextual type system. :has() lets CSS inspect the contents and relationships of elements, eliminating most presentational classes. Native nesting. Container queries. @starting-style for entry and exit transitions. Most of these shipped in the last two years. None existed in practical cross-browser form five years ago. Together, these form a new paradigm.
Call it Platform-First CSS. After fifteen years of frameworks built to patch the platform, here is one that runs on it. You don’t reach past CSS any more. You reach into it.
I have been writing CSS since it first landed in Netscape Navigator 4.0 in 1997 — coming up on three decades. Eight years on preprocessors. Five on the various pre-utility workaround layers. Four on Tailwind. Every one of these casts has been on my arm at some point. Every one came off again.
So — three casts, three layers of workaround, one underlying paradigm. Let’s take them off, one at a time.
The naming-convention cast
In 2013 I had a brief love affair with SMACSS. The first weekend was almost zen — the conventions were thoughtful, the philosophy was clearly onto something, and the part of my brain that had been holding the cascade in its context finally got to relax for a few hours. Within weeks however, the housekeeping was eating the project. CSS variables didn’t exist yet, so the modifier classes multiplied: .button--primary, .alert--primary, .badge--primary, each one duplicating the same color value in a slightly different declaration. Adding SASS rescued the ergonomics, but felt a bit like fixing your bike by throwing it on the back of a truck and driving instead. The discipline of remembering which prefix went with which module never became automatic, and SMACSS never quite earned a place in my default toolkit.
That was twelve years ago. The thing SMACSS (and BEM and OOCSS) were trying to do — give every selector a bounded address so it couldn’t collide with another component’s styles — is now what @scope does, but enforced by the parser instead of by a twenty-page convention document.
@layer components {
@scope (.card) {
:scope { background: var(--surface); padding: var(--space-m); }
.title { font-size: var(--text-lg); font-weight: 600; }
.body { line-height: 1.6; }
}
}
Here, .title inside .card and .title inside any other component are completely separate selectors. There is no .card__title. There is no naming convention. There is no slow accrual of compound class names that read like German nouns. You can call your inner elements .title and .body and .actions across the entire codebase, and the browser will keep them apart for you.
And not just within one team’s codebase. Two teams in different parts of a large organization can both define .actions inside their own scopes without coordinating, without sharing a naming ledger, and without anyone owning a global compatibility table. This is the thing that finally lets no-build vanilla CSS scale past the two-pizza team.
Conventions are not architecture. @scope is architecture.
The runtime cast
Throughout my career, different companies have tried different methods for taming the cascade — CSS-in-JS, styled-components, hand-rolled variations on CSS Modules. Each one bought us a ticket out of !important hell, and each one paid for it with our ability to keep a consistent style across the site over time. The styles lived in the components, which meant the design system lived in the components — which meant the design system was now duplicated across every component file, drifting in tiny ways from one PR to the next.
I realized it during a theme-switch implementation. To turn dark mode on, the codebase needed a context provider, a hook in every styled-component file, and a re-render of every leaf node so the new theme prop could trickle down to the color declarations. To me, this amount of structural housekeeping for something so trivial was unacceptable: in my mind, all it should have needed was an attribute on a <section> element.
[data-theme="dark"] {
--surface: #1a1a1a;
--on-surface: #e5e5e5;
}
Wrap any subtree in <section data-theme="dark"> and every component inside it adapts. No prop drilling. No context provider. No re-render. The cascade does what it has always done — it passes values down the tree — and you just give it better values to pass. Once @property lets you type the color tokens, the theme switch can also transition smoothly, because the browser knows the values it is interpolating between are colors. An entire layer of React state plumbing collapses into an HTML attribute and a CSS rule.
Styles handle their own contexts again. Components stop pretending to be stylesheets. Separation of concerns, restored.
The utility cast
I never liked Tailwind. The syntax is ugly. It is a regression to the era of <FONT> tags and inline styles, dressed in a modern build pipeline. It takes a perfectly valid idea — single-purpose utility classes — and pushes it well past the point where the idea was useful.
My breaking point was the official launch of the JIT compiler. The pitch was that the new compiler would solve the bundle-size problem. In the demo, the build took twenty seconds. The resulting dev build was twelve megabytes.
To get back to a sane production bundle, you needed tree-shaking, content scanning, a config file that needs its own documentation site, and the patience to debug why your dynamic class names had silently vanished from the build. None of that complexity existed before Tailwind. The problem it was solving did not exist before Tailwind. The framework multiplied class names by every breakpoint, every state, every variant; the compiler then had to subtract them back out. The plumbing needed to fix the framework was bigger than the framework.
Take this cast off and you find that the limb works. It was the cast that was always clumsy — slow to bend, indifferent to context, full of ways to chafe. The longer it has been on, the harder it is to remember what writing CSS without one used to feel like.
@layer components {
@scope (button[data-variant]) {
:scope {
padding: var(--space-s) var(--space-m);
border-radius: var(--radius-m);
}
:scope[data-variant="primary"] { background: var(--color-primary); }
:scope[data-variant="danger"] { background: var(--color-danger); }
}
}
Two scoped rules. Token-driven. No build step. The class on the button is data-variant="primary", which is the same number of bytes you would have spent on bg-primary text-white px-4 py-2 rounded, and the meaning lives in the markup instead of being scattered across half a dozen utility classes that the next person to touch this file will have to grep for.
A thin utility shelf still has a place — forty or fifty rules at most, derived from the same tokens, used to fill compositional gaps between components. The mortar between the bricks. The escape valve. Not the whole framework.
The pushback
The workarounds had reasons. The reasons deserve answers — not a wave of the hand.
“CSS is hard. Tailwind makes it easier.”
CSS was hard. The cascade was a minefield. Naming was a psychic discipline. Theming meant copying values everywhere or fighting a preprocessor’s variable scope. None of that is true now. With @scope, every component name is local. With @layer, every priority is explicit. With @property and a data- attribute, every theme switch is one line. The thing Tailwind was making easier no longer needs to be made easier. For the parts of CSS that many devs still find challenging – like positioning, alignment, or responsive media queries – there is no shame in going to Stephanie Eckles’ SmolCSS site and copying a utility class or six. That’s where utility classes shine.
“But the browser support — can I really use @scope and @property in production?”
Yes. As of May 2026, global support sits at 95.4% for @layer, 91.1% for @scope, 94.7% for @property, 94.3% for :has(), and 91.2% for @starting-style. These are not bleeding-edge numbers. They are baseline-2024, with eighteen months’ safety margin.
And one more honorable mention. Native @mixin rules landed in Chrome Canary last year. With a little luck, the last feature SASS still genuinely offered will be part of the platform itself in the not-too-distant future.
“What about animations? CSS-in-JS won partly because React gave us animation power that pure CSS didn’t have — keyframe choreography, spring physics, FLIP transitions.”
True, for a while. CSS keyframes caught up in 2016. The linear() easing function landed in late 2023 — spring physics in pure CSS, no library. View Transitions have been broadly available since 2025, handling FLIP-style animations natively. Most of what required Framer Motion or react-spring is now a stylesheet.
And when an animation genuinely has to be driven by JavaScript — interactive scrubbing, gestures, runtime physics — the integration point is one line: element.style.setProperty('--x', value). JavaScript pokes the cascade. The cascade does the rest. No stylesheet inside the component required.
“My team already knows BEM / Tailwind / styled-components. Switching is expensive.”
Knowing how to walk on crutches is not a transferable skill once the cast is off. The investment your team made in BEM was in remembering a convention. The parser does that work now. The investment in Tailwind was in memorizing thousands of utility class names; the platform provides what those classes were emulating. The cost of switching is non-zero, but it is paid once. The cost of not switching is paid every quarter, in the form of a build pipeline that needs care, a bundle size that needs trimming, and a design system that lives in twelve places at once.
Platform-First CSS is not a framework. There is no npm install, no config file, and no build step, though you can add one if you want file splitting or minification. Your choice, not a requirement. Platform-First CSS is an architectural pattern: the layer stack as the spine, scopes as the boundaries, typed properties as the type system, :has() and container queries as the relational and dimensional primitives. The composition primitive — native @mixin — is waiting in the wings. You can implement it hand-rolled. You can implement it using a kit. The paradigm is the platform itself.
It does not ask you to unlearn CSS. It asks you to stop fighting the cascade and start declaring it with intent. It asks you to use the CSS that actually exists today, instead of the CSS you learned to work around in 2015.
The cast is off. The limb is healed. We developers just need to use it again.