Native CSS Scoping With @scope
Native CSS @scope restricts selectors to a component or donut boundary, with proximity-based cascade rules and no build step.
@scope is a CSS at-rule that restricts where a block of selectors can match, from a root element down to an optional lower boundary, with no build step and no added specificity. MDN gives it Baseline “newly available” status dated March 2026.
If you maintain a component codebase, you already pay for style containment somewhere: a naming convention you enforce in code review, a bundler plugin that hashes class names, or a runtime that injects style tags. Each of those exists because a plain descendant selector reaches too far, and a chained child selector welds the CSS to one exact DOM shape.
Key Takeaways
- Inside an
@scopeblock a bare selector keeps only its own specificity, because the implied prefix is:where(:scope)and:where()contributes zero weight; writing:scopeexplicitly adds 0-1-0. @scope (.card) to (.card__content)matches elements between the card and its content slot: the root is included, the limit element and everything beneath it are excluded.- When two scoped declarations tie on specificity, the one whose scope root is fewer DOM hops from the element wins, regardless of source order.
- Scoping proximity is compared after importance, cascade layers and specificity and before source order, so a higher-specificity unscoped selector still overrides a scoped rule.
@scopelimits where selectors match; it does not stop inherited properties such ascolorfrom flowing past the scope limit into the excluded region.
Why Do Selectors Leak, and What Do BEM, CSS Modules and CSS-in-JS Do About It?
Every CSS selector matches against the whole document, so targeting “the hero image in this card” forces a choice between a selector that is too structural and one that is too broad.
.card > .card__body > img { } /* 0-2-1, breaks when the markup moves */
img { } /* 0-0-1, matches every image on the page */
.card__img { } /* 0-1-0, BEM: a unique name per component */
BEM solves the reach problem with naming discipline: one unique block name, and every inner element carries a block__element class so a bare .title never exists. CSS Modules automate the same idea by rewriting each class name to a hashed, file-local identifier at build time. CSS-in-JS libraries do it at runtime or compile time by generating those identifiers from your component code; the current state of that ecosystem is covered separately. The CSS Cascade Level 6 draft spells out how those tools work under the hood: they tag every element in a component with a marker attribute or class, then add that marker to every selector in the file.
| Approach | Build step | Lower boundary (donut) | Proximity in the cascade | Extra specificity |
|---|---|---|---|---|
| BEM | No | By naming convention only | No | Class-level per name |
| CSS Modules | Yes | Per-file hashed classes | No | Class-level per name |
| CSS-in-JS | Runtime or compile | Generated class per component | No | Class-level per name |
@scope | No | Native to (limit) clause | Yes | None from the root |
The Basic CSS Scope Block: @scope (.card)
@scope (.card) { img { } } matches only <img> elements that are inclusive descendants of a .card, and the .card root contributes nothing to the specificity of img.
<article class="card">
<img src="hero.jpg" alt=""> <!-- matched -->
</article>
<img src="logo.svg" alt=""> <!-- not matched -->
@scope (.card) {
img { border-radius: 8px; } /* specificity 0-0-1 */
:scope { padding: 1rem; } /* specificity 0-1-0, the .card itself */
}
MDN’s notes on specificity inside a scope explain the mechanism. A bare selector in the block is matched as though the prefix :where(:scope) sat in front of it, and since :where() carries no weight of its own, the root adds nothing to the total. This is the reverse of what attribute-stamping tools do: a generated [data-v-abc123] hook adds attribute-level weight to every selector it touches. Written explicitly, :scope is a normal pseudo-class and adds 0-1-0, so :scope img is 0-1-1.
What Is a Donut Scope?
A donut scope, written @scope (.card) to (.card__content), styles everything from the card down to, but not including, .card__content and its subtree, which is exactly the slotted-content problem that nested components create.
<article class="card">
<img src="hero.jpg" alt=""> <!-- in scope -->
<div class="card__content">
<img src="inline.jpg" alt=""> <!-- excluded: below the limit -->
</div>
</article>
@scope (.card) to (.card__content) {
img { border: 4px solid goldenrod; }
}
By default the root itself counts as in scope and the limit element does not. Appending > * to either selector flips that boundary. @scope (.card) to (.card__content > *) brings the .card__content element itself into scope while still excluding its children, which is useful when the slot wrapper needs padding but the slotted content must be left alone. In the draft’s definition, an element qualifies when it sits at or below the root, and neither is nor sits below a limit. No selector combination expresses “descendant of X but not inside Y” without either a boundary class on every element or :not() chains that reintroduce specificity.
Scoping Proximity Beats Source Order
When two scoped rules tie on specificity, the declaration whose scope root is closest to the element wins, which fixes the nested-theme bug that plain descendant selectors get wrong.
<div class="theme-light">
<p>Light</p>
<div class="theme-dark">
<p>Dark</p>
<div class="theme-light">
<p>Light again?</p>
</div>
</div>
</div>
With ordinary selectors, the innermost paragraph matches both .theme-light p and .theme-dark p at 0-1-1, so whichever rule appears last in the stylesheet wins, and the paragraph renders in the dark theme’s colour despite sitting inside a light container.
@scope (.theme-light) {
p { color: #1b1b1b; }
}
@scope (.theme-dark) {
p { color: #f2f2f2; } /* declared later, but loses on the inner p */
}
The innermost <p> is one hop from its .theme-light root and two from .theme-dark, so the light rule applies. MDN walks through the same worked example, and under the spec’s proximity rule a rule with no scoping root can never win this contest, because its hop count counts as infinite.
Where Does Proximity Sit in the Cascade?
Scoping proximity is compared after importance, cascade layers and specificity and before source order, so an unscoped selector with higher specificity still overrides a scoped rule no matter how close the scope root is.
The Cascade 6 sorting order lists seven criteria in descending precedence:
- Origin and importance
- Context (shadow-tree encapsulation)
- The style attribute
- Cascade layers
- Specificity
- Scope proximity
- Order of appearance
Proximity is a tiebreaker for specificity, not a replacement for it:
@scope (aside) {
p { color: green; } /* 0-0-1, scoped */
}
aside#sidebar p { color: red; } /* 1-0-2, unscoped, wins */
<aside id="sidebar"><p>This is red.</p></aside>
Ranking proximity below specificity was a deliberate choice, and the draft’s change log records the removal of the stronger version that would have outranked it. The reasoning appears in the explainer behind the feature: if proximity won first, specificity would only settle contests between selectors at the same proximity, so rules written to sit above or below each other would start winning and losing on the shape of the DOM instead. If you expect scoped styles to behave like Shadow DOM encapsulation, this is the point where that expectation fails.
Selectors Are Scoped; Inheritance Is Not
@scope limits where a selector can match; it does not stop inherited properties from flowing past the scope limit, so a color set on the scope root still reaches every element inside the excluded region.
<article class="card">
<p>Card text</p>
<div class="card__content">
<p>Slotted text: also hotpink, with no border</p>
</div>
</article>
@scope (.card) to (.card__content) {
:scope { color: hotpink; } /* inherited: crosses the limit */
p { border: 1px solid currentColor; } /* not inherited, and p in the slot is out of scope */
}
The slotted paragraph is outside the scope, so no scoped selector matches it and it gets no border. It still renders in hotpink, because inheritance is a property-level mechanism that runs after the cascade and knows nothing about scopes. The @scope reference makes the same point: scoping fences off which elements a selector can reach, not where the resulting styles end up. Anything you want contained at the slot boundary needs an explicit reset on the slot itself, the same as it always did.
Which Browsers Support @scope, and When Should You Use It?
MDN puts @scope at Baseline “newly available” as of March 2026, which means every current major engine ships it. The compatibility data shows first support in Chrome and Edge 118 and Firefox 146; Safari 17.4 shipped it, Safari 26.0 through 26.3 are marked partial, and Safari 26.4 restored full support. Browsers that lack it drop the whole at-rule, as CSS requires for unrecognised constructs, so a scoped block degrades to nothing rather than to a broken rule.
Reach for @scope when a component owns a region of the tree but must not style what is slotted into it, or when the same component nests inside itself with different variants. Leave it alone for global resets, typography and brand tokens: those should flow through, and the cascade is built so that they do. Where you need to order whole stylesheets against each other rather than fence off subtrees, cascade layers remain the right tool.
@scope moves containment from your build pipeline into the browser: donut boundaries and proximity resolution are cascade features now, not conventions or generated hashes. The practical next step is to take one component that currently relies on a __element suffix or a hashed class to keep its inner slot untouched, rewrite it as a @scope (.component) to (.slot) block, and check that any colour or font you expected to stop at the slot is reset there explicitly.
FAQs
Can I feature-detect @scope with @supports, and what happens in browsers that lack it?
Browsers without @scope support discard the at-rule block, so the scoped rules do not apply and nothing else breaks. CSS Conditional Rules Level 5 defines @supports at-rule(@scope), but at-rule() is newer than @scope (Chromium 148 shipped it first), so any browser old enough to lack @scope also lacks the detection function. Write plain fallback rules of equal or lower specificity outside the block; supporting browsers override them with the scoped rules.
Can I use @scope without a root selector?
Yes. Inside an HTML style element you can write @scope with no root selector in its prelude, and the browser scopes the enclosed rules to the parent element of that style element. The inline form also accepts a limit, written as @scope to (.card__content). This suits server-rendered fragments that ship their own styles. In ordinary stylesheets, use the (root) prelude form so the scope root is explicit.
Can I read @scope rules from JavaScript?
Yes, through the CSSScopeRule interface, which extends CSSGroupingRule. It exposes two read-only string properties: start returns the serialized scope root selector and end returns the scope limit selector, each null when that part of the prelude is omitted. Reach a rule through document.styleSheets and its cssRules list, and access the scoped style rules inside it via the cssRules property inherited from CSSGroupingRule.
What is the difference between @scope and Shadow DOM for style encapsulation?
Shadow DOM creates a separate DOM tree with a hard style boundary: outer selectors cannot match inside a shadow root except through ::part, and inner rules cannot reach out. @scope changes nothing about the markup; it only limits where the selectors inside one block can match, so other stylesheets can still target every element in the scope. Inherited properties cross both boundaries. @scope also needs no JavaScript or shadow root.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k