Is CSS @scope Finally the Answer to Style Leakage?
For years, managing CSS at scale has felt like a losing battle. The cascade — CSS's core feature — often becomes the enemy when styles meant for one component quietly invade another. Developers respond by either ratcheting up selector specificity or by abandoning the cascade entirely in favor of JavaScript-driven styling.
BEM and similar naming methodologies were designed to impose order. A class like app-user-overview__status--is-authenticating theoretically tells you everything about an element's role and state. In practice, however, naming conventions break down under real-world pressure: priorities shift, HTML changes, and adherence becomes inconsistent. When that happens, the convention's structural guarantees evaporate, leaving you with all the verbosity and none of the safety.
The industry's most popular alternative — utility-first frameworks like Tailwind — avoid the problem by sidestepping the cascade altogether. But that approach exacts a toll: heavy build pipelines, autogenerated class names like .jsx-3130221066, and a debugging experience constrained to specific compiled development versions rather than native browser tooling. An increasing amount of frontend work now requires tooling to debug the tooling used to abstract away standard CSS features, all because the cascade felt too painful to manage.
Introducing @scope
The CSS @scope at-rule offers a middle path. As MDN describes it, @scope lets you "select elements in specific DOM subtrees, targeting elements precisely without writing overly-specific selectors that are hard to override, and without coupling your selectors too tightly to the DOM structure." This allows you to write isolated styles without abandoning inheritance, cascading, or separation of concerns.
The feature recently achieved an important milestone: with Firefox 146 adding support in December, @scope is now Baseline compatible across all major browsers, making it the first time developers can use it without workarounds.
Take a simple button comparison. With BEM, you need descriptive block-element-modifier names to isolate styles. With @scope, you target the native HTML element directly:
<!-- BEM -->
<button class="button button--primary">
<span class="button__text">Click me</span>
<span class="button__icon">→</span>
</button>
<style>
.button .button__text { /* button text styles */ }
.button .button__icon { /* button icon styles */ }
.button--primary { primary button styles */ }
</style>
<!-- @scope -->
<button class="primary-button">
<span>Click me</span>
<span>→</span>
</button>
<style>
@scope (.primary-button) {
span:first-child { /* button text styles */ }
span:last-child { /* button icon styles */ }
}
</style>
The purpose is precision with less complexity. Instead of inventing and maintaining class names to create style boundaries, you use DOM structure as the boundary. Removing the burden of class name management alone can relieve much of the anxiety associated with large projects.
Basic Usage and Donut Scoping
Getting started is straightforward. You apply @scope with a root selector that determines where the scoped styles begin:
@scope (<selector>) {
/* Styles scoped to the <selector> */
}
For instance, scoping all styles to a <nav> element looks like this:
@scope (nav) {
a { /* Link styles within nav scope */ }
a:active { /* Active link styles */ }
a:active::before { /* Active link with pseudo-element for extra styling */ }
@media (max-width: 768px) {
a { /* Responsive adjustments */ }
}
}
On its own, that is useful but not revelatory. The rule becomes more powerful with an optional second argument: a lower boundary that defines where the scope ends. This is called donut scoping:
/* Any `a` element inside `ul` will not have the styles applied */
@scope (nav) to (ul) {
a {
font-size: 14px;
}
}
Previously, achieving this behavior required either highly specific selectors coupled to DOM structure, a :not pseudo-selector chain, or special class names for certain descendants. The @scope approach is more concise and, critically, won't break if class names change, are misused, or the HTML structure is modified.
You can layer multiple lower boundaries to create more complex scoping patterns — a "style figure eight," if you will:
/* Any <a> or <p> element inside <aside> or <nav> will not have the styles applied */
@scope (main) to (aside, nav) {
a {
font-size: 14px;
}
p {
line-height: 16px;
color: darkgrey;
}
}
Compare that to the traditional approach, where each nested element would need its own "reset" rules to undo unwanted styles:
main a {
font-size: 14px;
}
main p {
line-height: 16px;
color: darkgrey;
}
main aside a,
main nav a {
font-size: inherit; /* or whatever the default should be */
}
main aside p,
main nav p {
line-height: inherit; /* or whatever the default should be */
color: inherit; /* or a specific color */
}
The practical result is evident: you can easily target some nested selectors while exempting others, as shown in the following interactive example:
See the Pen [@scope example [forked]](https://codepen.io/smashingmag/pen/wBWXggN) by Blake Lundquist.
Scoping in Web Components
One notable application is in styling slotted content within web components. Content slotted into a Shadow DOM inherits styles from its parent light DOM, which can create confusing behavior when you need the same content to appear differently depending on where it's placed:
<!-- Same <user-card> content, different contexts -->
<product-showcase>
<user-card slot="reviewer">
<img src="avatar.jpg" slot="avatar">
<span slot="name">Jane Doe</span>
</user-card>
</product-showcase>
<team-roster>
<user-card slot="member">
<img src="avatar.jpg" slot="avatar">
<span slot="name">Jane Doe</span>
</user-card>
</team-roster>
For example, you could give a <user-card> element distinct styles only when it is rendered inside a <team-roster> component:
@scope (team-roster) {
user-card {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
user-card img {
border-radius: 50%;
width: 40px;
height: 40px;
}
}
Flexibility Beyond Class Names
The @scope rule expands targeting options well beyond class names. You can scope styles to any descendant of any selector, opening up styling to native elements and attribute selectors without enforcing a particular naming convention:
/* Only div elements with a direct child button are included in the root scope */
@scope (div:has(> button)) {
p {
font-size: 14px;
}
}
Scopes are also nestable, allowing you to create scopes within scopes for increasingly granular control:
@scope (main) {
p {
font-size: 16px;
color: black;
}
@scope (section) {
p {
font-size: 14px;
color: blue;
}
@scope (.highlight) {
p {
background-color: yellow;
font-weight: bold;
}
}
}
}
Within a scope, you can reference the root itself to apply styles relative to the scope's base element directly:
/* Applies to elements inside direct child `section` elements of `main`, but stops at any direct `aside` that is a direct chiled of those sections */
@scope (main > section) to (:scope > aside) {
p {
background-color: lightblue;
color: blue;
}
/* Applies to ul elements that are immediate siblings of root scope */
:scope + ul {
list-style: none;
}
}
Aside from these conveniences, @scope introduces a new dimension to CSS specificity: proximity. In traditional cascading, when two selectors of equal specificity match an element, the one declared later wins. With @scope, if two matching selectors have equal specificity, the one whose scope root is closer — in the DOM — to the matched element takes precedence. This removes the need to artificially inflate specificity on inner elements to outrank outer component styles.
<style>
@scope (.container) {
.title { color: green; }
}
<!-- The <h2> is closer to .container than to .sidebar so "color: green" wins. -->
@scope (.sidebar) {
.title { color: red; }
}
</style>
<div class="sidebar">
<div class="container">
<h2 class="title">Hello</h2>
</div>
</div>
A Simpler Path to Maintainable CSS
Utility-first frameworks have their place. They are efficient for prototyping and small projects, but their benefits diminish when a larger team works on a sizeable codebase over time. The same can be said for rigid naming methodologies that depend on consistent discipline to stay intact.
The cascade is not something to escape; it is something to control. Modern CSS features like Cascade Layers are one route to that control, and @scope is another. While not a cure-all, @scope can reduce dependence on complex tooling and prescriptive naming, making standard CSS viable for larger projects. Used alongside — or instead of — strategic class naming, it offers a way back to writing maintainable styles that don't require fighting the native web platform.



