DEV Community

Vivian Voss
Vivian Voss

Posted on • Originally published at vivianvoss.net

The CSS Scope You Never Had to Compile

A young developer in a brick-walled architectural studio at dusk, standing beside a blueprint that diagrams the CSS cascade as a five-storey building. The 'Scoping Proximity' floor glows amber, marked 'NEW'. The labels read top to bottom: Importance, @layer, Specificity, Scoping Proximity, Source Order.<br>

Stack Patterns — Episode 13

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

.card h3 { font-weight: 600; }
Enter fullscreen mode Exit fullscreen mode

Then a third-party widget on the same page also has a .card with an h3, and your bold heading is now wrestling someone else's typography. So 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 older than most reading this article. In 2013, the original CSS Scoping Module Level 1 included a <style scoped> HTML attribute and an @scope block. The plan was simple: scope styles to part of the DOM declaratively. 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 gravitated to Shadow DOM, which solved a different and stricter problem (full encapsulation), and to JavaScript-based scoping libraries, which solved the original problem at the cost of a build step and a runtime.

The current @scope rule is, in effect, the second attempt. The explainer was written by Miriam Suzanne of Oddbird and updated through 2022, and the work was 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 (December 2025). It reached "Newly available" Baseline status in December 2025. Global usage is just over 91 percent.

It only took, in total, 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; }
}
Enter fullscreen mode Exit fullscreen mode

(.card) is the upper bound, where the scope begins; .embedded-card is the lower bound, where it ends. Rules inside the block apply to elements between the two boundaries, but never to elements inside an .embedded-card. The pattern has the 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.

When the stylesheet lives inside the component itself, the prelude can be omitted entirely:

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

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 all along, except styled-components costs 25 KB and a Babel transform, and the browser version costs zero.

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), the same as a bare h3 outside any scope. The CSS spec describes this as the bare selectors behaving as if :where(:scope) were prepended, where :where() is the pseudo-class that adds 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 are still possible without !important arms races. @scope solves both at once, by changing the cascade rather than the markup.

The cascade itself gains a new sorting tier called scoping proximity. The official cascade order, in the CSS Cascade Level 6 Working Draft, now 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.

This makes nested theme switching a quiet pleasure:

@scope (.dark)  { p { colour: white; } }
@scope (.light) { p { colour: black; } }
Enter fullscreen mode Exit fullscreen mode

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.

Honest Limitations

The two things to know before you ship @scope to production:

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 you target browsers older than late 2024, prefer explicit selectors over & for the moment.

@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. If you need true encapsulation (where outside CSS cannot reach in either), Shadow DOM still has a job.

When to Use

Anywhere you currently use BEM naming to fake scoping. Anywhere you reach for CSS Modules. Anywhere you considered styled-components or Emotion for the third time today. Anywhere a third-party widget's stylesheet leaks into yours, and you 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 you can read top-to-bottom and reason about with no surprises.

The Cascade, Civilised

Episode 03 of this series tackled @layer: the rule that decided which stylesheet's voice prevails when several try to speak. @scope decides where each voice may speak in the first place. Together 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.

Read the full article on vivianvoss.net →


By Vivian Voss — System Architect & Software Developer. Follow me on LinkedIn for daily technical writing.

Top comments (0)