Vivian Voss

The CSS Scope You Never Had to Compile

css web html

Stack Patterns ■ Episode 13

Every web developer has fought this particular fight. You write a perfectly innocent rule:

.card h3 { font-weight: 600; }

Then a third-party widget on the same page turns out to have a .card of its own, with an h3, and your bold heading is now in animated negotiation with someone else's typography. You add a class. Then a more specific class. Then you wrap your component in a unique parent selector. By the end of the year you have either adopted BEM and renamed every selector to .product-card__heading--featured, installed CSS Modules and accepted a build step, or surrendered to 80 KB of styled-components. CSS, in its capacity as a global namespace, has won again.

@scope is the native CSS answer, and, as of December 2025, it works in every modern browser.

A Brief, Slightly Embarrassing History

The idea is rather older than most of the people currently enthusiastic about it. In 2013, the original CSS Scoping Module Level 1 included a <style scoped> HTML attribute and an @scope block. The plan was simple: declare a region of the DOM, scope styles to it. It was never implemented. By 2018, CSS Cascade Level 4 quietly removed scoping from the cascade sort criteria altogether, with the polite editorial note that nobody had built it. The community drifted towards Shadow DOM (which solved a different, stricter problem) and towards JavaScript-based scoping libraries (which solved the original problem at the cost of a build step and a runtime). One does note the asymmetry.

The current @scope rule is, in effect, the second attempt. The explainer was written by Miriam Suzanne at Oddbird and driven through the CSS Working Group with input from Google, Mozilla, and Apple. Chrome shipped it in version 118 (October 2023), Safari in 17.4 (March 2024), and Firefox in 146 in December 2025, at which point it reached "Newly available" Baseline status across the board. Global usage is now just over 91 per cent. In total, from the first draft, twenty-five years.

The Pattern

The standalone form takes a scope root and an optional scope limit:

@scope (.card) to (.embedded-card) {
    img { border-radius: 0.5rem; }
    h3  { font-weight: 600; }
}

(.card) is the upper bound, where the scope begins; .embedded-card is the lower bound, where it ends. Rules apply to elements between the two boundaries, but never to elements inside an .embedded-card. The pattern has a rather charming nickname: donut scope. Style the doughnut, leave the hole alone. The hole is wherever a nested component takes responsibility for its own styling, and you politely stop interfering.

Donut Scope @scope (.card) upper bound — rules start here h3 → font-weight: 600; img → border-radius: 0.5rem; a → text-decoration: underline; to (.embedded-card) hole — rules stop here nested component is left to its own devices Style the doughnut. Leave the hole alone.

When the stylesheet lives inside the component itself, the prelude can be omitted entirely, and things become rather pleasantly compact:

<article-card>
    <style>
        @scope {
            :scope { padding: 1rem; border: 1px solid #ddd; }
            h3 { font-weight: 600; margin: 0 0 0.5rem; }
            p  { color: #555; line-height: 1.5; }
        }
    </style>
    <h3>Title</h3>
    <p>Body copy.</p>
</article-card>

The implicit scope root is the <style> element's parent. The :scope pseudo-class refers to that root, so :scope matches the <article-card> itself. Drop the markup anywhere on the page and the rules travel with it, scoped to exactly where they should be. This is, conceptually, what styled-components and Vue's scoped CSS have been doing for a decade, except styled-components costs 25 KB and a Babel transform, and the browser version costs zero. One has, in two lines of CSS, reinvented an entire library category, and one did not have to install Babel.

Why It Works

The detail that makes @scope properly elegant is what it deliberately does not do. The scope root contributes nothing to specificity. A bare h3 inside @scope (.card) has specificity (0,0,1), exactly the same as a bare h3 outside any scope. The CSS Cascade Level 6 Working Draft describes this as the bare selectors behaving as if :where(:scope) were prepended, and :where() is the pseudo-class that adds precisely zero specificity.

This sounds like a small thing. It is, in fact, the entire reason CSS-in-JS exists. Every framework that hashes class names is solving two problems at once: scoping the rules so they only apply where intended, AND keeping specificity low so future overrides remain possible without an !important arms race. @scope solves both at once, by changing the cascade rather than the markup. One does, on reflection, find this a rather handsome piece of spec-writing.

The cascade itself gains a new sorting tier: scoping proximity. The revised cascade order, per Cascade Level 6, reads roughly: importance first (!important), then cascade layers (@layer), then specificity, then scoping proximity, then source order. When two @scope blocks produce conflicting declarations for the same element, the rule whose root is fewer DOM hops away from that element wins.

The Cascade Order — Cascade Level 6 1 Importance !important wins first 2 Cascade Layers @layer order 3 Specificity (0,0,1) vs (0,1,0) vs (1,0,0) 4 Scoping Proximity NEW (2025) 5 Source Order later in stylesheet wins Two @scope blocks collide: the root fewer DOM hops away wins.

Which makes nested theme switching a quiet pleasure:

@scope (.dark)  { p { color: white; } }
@scope (.light) { p { color: black; } }

A .light wrapper inside a .dark wrapper produces light paragraphs. A .dark wrapper inside a .light wrapper produces dark ones. Nest as deeply as you like; the closest ancestor with a matching scope wins. There is no JavaScript, no media query stack, no design token lookup. The DOM tree is the lookup.

The Old Ways, Priced Out

The Cost Sheet BEM 0 KB runtime discipline cost product-card__ heading--featured CSS Modules ~10 KB runtime yes build step hashed class names everywhere styled-components ~25 KB runtime Babel transform CSS inside JS inside React @scope 0 KB runtime 0 build step native, 91% support The build-step argument has, rather quietly, expired.

Honest Limitations

Two things worth knowing before one ships @scope to production, in the spirit of not selling things one does not entirely own.

First, the & selector inside @scope blocks has had subtle interoperability differences across engines, particularly in earlier versions. The behaviour is now well-specified (& selector desugars through :is() and inherits its specificity rules), but if one targets browsers older than late 2024, prefer explicit selectors over & for the moment. It will come right; it has come right; it just wasn't, briefly, right everywhere at once.

Second, and more philosophically: @scope is style scoping, not full encapsulation. Your scoped styles do not leak out, but the global cascade still leaks in. A high-specificity rule from elsewhere on the page can still override your scoped rule. This is a feature, not a bug: it means design system tokens, accessibility overrides, and user stylesheets all continue to work as intended. If one needs true encapsulation, where outside CSS cannot reach in either, Shadow DOM still has a job. Different problem, different tool.

When to Use

Anywhere one currently uses BEM naming to fake scoping. Anywhere one reaches for CSS Modules. Anywhere one considered styled-components or Emotion for the third time today. Anywhere a third-party widget's stylesheet leaks into one's own, and one would rather solve that with a one-line @scope boundary than a class-name renaming campaign.

For a fresh design system, build it in scopes from the start. Each component declares its @scope block; the global stylesheet handles tokens and base typography in unscoped form. The result is a CSS file one can read top-to-bottom and reason about with no surprises, which is, after twenty-five years of the genre, a rather novel experience.

The Cascade, Civilised

Episode 03 of this series tackled @layer: the rule that decided which stylesheet's voice prevails when several try to speak at once. @scope decides where each voice may speak in the first place. Between them, they cover the two great unsolved problems of CSS architecture: ordering and bounding. Both have now been quietly solved by reading the spec, rather than by installing the next generation of build tools.

The cascade, after twenty-five years, is finally finished.

@scope: native, 91% support since December 2025. Scope root adds zero specificity. "Donut scope": style the doughnut, leave the hole alone. New cascade tier: scoping proximity. Nested theme switching in two lines of CSS. Replaces BEM, CSS Modules, styled-components (~25 KB + Babel). Style-in-JS finally answered by the browser.