Skip to content
All posts
#CSS#Architecture#Performance#React

Scoped Styles Without a Build Step: The @scope At-Rule

7 min read

The @scope at-rule limits selector reach in pure CSS — and adds scoping proximity, a zero-weight & rewrite, and a hard line between selector and style isolation.

For a decade the answer to "how do I stop this component's styles leaking" was a build step: hash the class names, rewrite the selectors, ship a tool. The @scope at-rule answers it in CSS itself, selecting elements in specific DOM subtrees without overly-specific selectors that are hard to override or tight coupling to the DOM structure.[1]

It does more than limit reach: it adds a criterion to the cascade, changes how the nesting selector resolves, and draws a sharp line between selector isolation and style isolation.[1][3]

The Problem @scope Actually Solves

Take the canonical case: you want the hero image in the content area of the card component. Write .card > .content > img.hero and you have a selector with a "pretty high specificity of (0,3,1) which makes it hard to override as your code grows", bolted to the DOM structure by the child combinators.[10] Drop down to bare img and you select every image on the page.[10]

The historical escapes were all workarounds: BEM-style class naming, or JavaScript-based solutions such as Scoped CSS and Styled Components that rewrite selectors with randomly generated strings so they stop targeting elements on the other side of the page.[10] @scope keeps the low-specificity selector and constrains its reach instead: @scope (.card) { img { border-color: green; } } can only match images in scope of a matched .card.[10]

Root, Limit, and the Donut

The prelude is a scope root plus an optional scope limit: @scope (scope root) to (scope limit) { }.[1] The spec defines the boundary precisely — an element is in scope if it is an inclusive descendant of the scoping root, and not an inclusive descendant of a scoping limit.[3] MDN puts the asymmetry plainly: the upper bound is inclusive, the lower bound exclusive. The result is a shape both sources call a donut scope.[1][10]

Inside the block, selectors are relative selectors with the scoping root and a descendant combinator implied at the start, so p and :scope p match the same elements; an explicit > changes that implied relationship.[3] Inline the at-rule in a <style> element with no prelude and the rules are scoped to that element's parent — Chrome notes this works "because that div is the <style> element's parent element".[1][10]

Specificity: Why the Prelude Doesn't Count

This is the part that surprises people who expect nesting semantics. Unlike nesting, selectors within an @scope rule do not acquire the specificity of any parent selector in the prelude.[3] Chrome's article states the same rule from the other direction: the selectors you use in the prelude for @scope do not affect the specificity of the contained selectors, so img stays at (0,0,1) even inside @scope (#sidebar).[10]

The contents do something subtler. Inside an @scope rule, both bare selectors and the & nesting selector behave as if :where(:scope) were prepended to the selector, and :where() contributes zero specificity.[1] The spec states it directly: the & selector is defined to behave as :where(:scope).[3] The consequence is that bare selectors and & add no weight at all — the specificity of & img is just the specificity of img.[1]

Two adjacent facts explain why that zero is deliberate. The & nesting combinator adds no weight, but nested rules do — nesting behaves much like :is().[4] And :where() always has 0 specificity, whereas :is() takes the specificity of its most specific argument.[6] The spec chose the :where() form here, so the zero is intended rather than incidental.[3]

:scope is the exception. Using it explicitly selects the scope root and adds class-level specificity (0-1-0), because :scope is a pseudo-class, so :scope img weighs 0-1-1.[1] The spec agrees: :scope has a specificity of (0,1,0), whereas & has a specificity of 0.[3]

That difference is not merely aesthetic, because the & behaviour is newer than the at-rule. MDN carries an explicit warning that the specificity of & inside @scope blocks is handled differently according to the browser engine and release version.[1] Browser-compat-data dates the convergence: & treated as :where(:scope) in @scope blocks arrived in Chrome 143, Firefox 142, and Safari 26.2.[8]

Scoping Proximity: A New Cascade Criterion

@scope also adds a criterion to the cascade. When comparing declarations that appear in style rules with different scoping roots, the declaration with the fewest generational or sibling-element hops between the scoping root and the scoped style rule's subject wins — and style rules without a scoping root are treated as having infinite proximity hops.[3] Chrome documents where the step sits: scoping proximity comes after specificity but before order of appearance.[10]

The nested-theme example shows why it matters. With plain .light a / .dark a rules, the innermost link inside a light card renders white instead of black, because .dark a was declared last and order of appearance decides.[10] Under @scope both scoped a selectors have the same specificity, so scoping proximity kicks into action: one hop to .light, two to .dark, so the light rule wins.[10] Proximity still is not the top of the cascade — it overrules source order but is itself overridden by importance, layers, and specificity.[1]

Selector Isolation, Not Style Isolation

The most common misreading of @scope is that it sandboxes styles. It limits the reach of the selectors; it does not offer style isolation.[10] MDN says the same: @scope does not completely isolate the applied styles to within the subtree, and this is most noticeable with inheritance.[1] A donut scope that sets :scope { color: hotpink } still paints the limit element and its children hotpink, because the colour inherits past the lower bound.[10]

Edges Worth Knowing

Scoped style rules cannot escape their subtree: selections like :scope + p are invalid because they would select elements outside the scope.[1] The :scope page generalises this — the relationship defined by :scope is always ancestor-to-descendant from the scope root, so a sibling combinator to the right of :scope creates a selector that can never match.[2]

Pseudo-elements cannot be scoping roots or limits; they are invalid in both scope-start and scope-end, because many pseudo-elements exist conditionally based on the styling of their ancestors and allowing them to be queried can introduce cyclic querying.[1][3]

:scope also means different things in different contexts. At the root of a stylesheet it is equivalent to :root; inside an @scope block it matches the block's scope root; in a DOM API call it matches the element the method was called on.[2] Outside a nested style rule, the & nesting selector likewise represents the scoping root.[5] And unlike shadow encapsulation's persistent one-to-one host/tree relationship, multiple overlapping scopes can be defined in relation to the same elements.[3]

For tooling, @scope is exposed to the CSS object model as CSSScopeRule, with start and end reporting the scope root and limit.[1]

Support Tiers and Rollout

The at-rule tier is Chrome 118, Firefox 146, Safari 26.4.[7] Safari shipped 17.4 through 26 without applying @scope rules to <input> and <textarea> elements, a partial implementation that compat data flags at 26 and removes at 26.4.[7] The nesting-selector half is narrower: & treated as :where(:scope) landed in Chrome 143, Firefox 142, and Safari 26.2, so code leaning on it has a higher floor than code needing only the at-rule.[8] The practical split follows from that. Use @scope for reach-limiting and proximity where the at-rule tier covers you; write :where(:scope) explicitly when you need zero-specificity — the spec defines & to behave exactly as :where(:scope), so the explicit form carries the same semantics without the version caveat.[3] web-features tracks the at-rule, CSSScopeRule, and the nesting at-scope case as one feature.[9]

Sources

[1] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope — MDN — @scope CSS at-rule

[2] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:scope — MDN — :scope CSS pseudo-class

[3] https://drafts.csswg.org/css-cascade-6 — CSS Cascading and Inheritance Level 6 — W3C CSSWG Editor's Draft

[4] https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Cascade/Specificity — MDN — CSS specificity

[5] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Nesting_selector — MDN — & nesting selector

[6] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:where — MDN — :where() CSS pseudo-class

[7] https://github.com/mdn/browser-compat-data/blob/main/css/at-rules/scope.json — mdn/browser-compat-data — css/at-rules/scope.json

[8] https://github.com/mdn/browser-compat-data/blob/main/css/selectors/nesting.json — mdn/browser-compat-data — css/selectors/nesting.json (at-scope)

[9] https://github.com/web-platform-dx/web-features/blob/main/features/scope.yml — web-platform-dx/web-features — features/scope.yml

[10] https://developer.chrome.com/docs/css-ui/at-scope — Chrome for Developers — Limit the reach of your selectors with the CSS @scope at-rule