CSS @scope: Scoped Styles Without Frameworks
A .card img rule quietly matching an unrelated nested image six months later is a familiar CSS failure mode. @scope gives you a real containment boundary instead of a naming convention.
Paul Radford — Full-Stack Developer & Editor. Paul is a full-stack developer and editor of template.tips. Articles here are AI-drafted and reviewed by Paul, who knows the code well enough to catch what's wrong and cut the hype.

The @scope rule lets you write CSS that applies only within a specific DOM subtree, using a scoping root and an optional lower boundary, so selectors stop leaking into unrelated markup or getting overridden by distant ancestor styles. It replaces a chunk of what CSS Modules, BEM naming, or shadow DOM previously handled at the cost of tooling.
What problem does @scope actually solve?
Every large CSS codebase eventually runs into the same failure mode: a selector written for one component quietly matches an element somewhere else. You write .card img { border-radius: 8px; }, and six months later a designer asks why every image inside a nested widget that happens to live inside a .card also got rounded corners. Specificity wars and increasingly long selector chains are the usual workaround, and both make the stylesheet harder to read and slower to change.
@scope gives you a real containment boundary instead of a naming convention. You declare a root, optionally a limit, and everything inside behaves like a mini stylesheet that cannot escape its box or be accidentally matched from outside.
1/* Styles below only apply to elements inside .card,2 and only until a nested .card is reached */3@scope (.card) to (.card .card) {4 img {5 border-radius: 8px;6 }7 h3 {8 font-weight: 600;9 }10}That img rule now cannot touch an <img> sitting inside a nested card, and it cannot touch an <img> outside any .card at all. No specificity increase, no :not() gymnastics, no BEM class on every element.
How does the scoping root and limit actually work?
The syntax is @scope (<scope-start>) to (<scope-end>) { ... }. The scope-start selector defines where matching begins. The optional scope-end (the "donut hole") defines a boundary that selectors inside the block cannot cross, even though the DOM continues past it.
A few behaviors trip people up the first time:
- The scoping root itself is included in the match. If you write @scope (.card) and then a bare .card selector inside the block, it matches the root element too, not just its descendants.
- The :scope pseudo-class inside the block refers to the scoping root, similar to how it works in querySelector calls from JavaScript. This is useful when you want to style the root differently from its descendants without repeating the selector.
- Distance to the scoping root, not raw specificity, breaks ties between competing @scope blocks. A rule from a scope whose root is closer in the DOM wins over one from a farther root, even if the farther one would normally win on specificity. This is a genuinely new cascade concept, not just sugar over existing rules, and it is documented in the CSS Cascading and Inheritance Level 6 draft.
1/* : scope targets the root element itself */2@scope (.card) {3 : scope {4 display: grid;5 gap: 0.75rem;6 }7 /* descendants get different treatment */8 p {9 margin: 0;10 color: #444;11 }12}Can I use @scope in place of shadow DOM encapsulation?
Not fully, and treating it as a drop-in replacement is a mistake I have seen more than once. Shadow DOM gives you actual style isolation at the rendering level: styles from the outside document cannot leak in unless you explicitly pierce through with ::part() or custom properties, and vice versa. @scope is still light-scoped CSS living in the same document. Global resets, inherited custom properties, and anything targeting * or html still cascade through a @scope block normally.
If you are building a design system component that a third party will drop into an unknown page (an embeddable widget, an ad unit, a checkout iframe alternative), shadow DOM or an actual iframe is still the safer boundary. @scope is the right tool when you own the whole page and just want to stop your own component styles from colliding with each other, which is the far more common case in day-to-day component work.
Handling the donut-hole boundary correctly
The scope-end selector is evaluated relative to the scope-start, and it excludes everything from the end boundary downward, not just the matched element. This is genuinely handy for things like card-in-card layouts, nested comment threads, or tab panels that might contain another instance of the same component.
1/* Comment thread styling that stops at nested replies,2 so a reply's own @scope block takes over from there */3@scope (.comment) to (.comment .comment) {4 .body {5 font-size: 0.95rem;6 line-height: 1.5;7 }8 .avatar {9 width: 32px;10 height: 32px;11 }12}Without the to (.comment .comment) limit, a top-level comment's font-size rule would happily apply to every nested reply as well, since they are all still descendants of the original .comment root. That is often what you want for shared visual language, but not always, and forgetting the limit is the single most common mistake I see in early adoption code.
What about browser support right now?
As of early 2026, @scope ships in Chrome and Edge from version 118 (released October 2023), and in Safari from version 17.4 (released March 2024). Firefox shipped support in version 128, released mid-2024. Practically, that means any browser released in the last two years handles it, but if your analytics show meaningful traffic from Safari versions below 17.4 or from older enterprise-locked Chromebooks, you need a fallback path. Check current numbers on caniuse before committing to it as your only styling strategy for a public-facing product, because enterprise environments lag further behind than consumer browser update cycles suggest.
There is no polyfill that replicates the cascade-proximity behavior correctly. Tools exist that fake basic scoping with attribute selectors, but they cannot reproduce how @scope interacts with specificity ties across multiple scopes, so treat it as progressive enhancement rather than a guaranteed layer. A reasonable pattern is to write your base styles as if @scope did not exist (sensible specificity, no accidental leakage through overly broad selectors) and then use @scope to clean up the remaining edge cases, so older browsers degrade to "slightly less tidy" rather than "broken."
Combining @scope with nesting and container queries
@scope composes well with native CSS nesting, since both landed in browsers around the same timeframe. You can nest selectors inside the scope block using & the same way you would in a plain nested rule, per the CSS Nesting specification.
1@scope (.dropdown) {2 : scope {3 position: relative;45 &[data-open="true"] .menu {6 display: block;7 }8 }910 .menu {11 display: none;12 position: absolute;13 inset-block-start: 100%;14 }15}It also plays fine with container queries, since a scoped block can contain a @container query and vice versa. This matters for component libraries where a card needs to both avoid leaking styles and respond to its own available width rather than the viewport, covered in detail on MDN's container queries page.
When @scope is the wrong choice
If your project already runs CSS Modules, Vue's <style scoped>, or CSS-in-JS with automatic class hashing, adding @scope on top buys you almost nothing and adds a second scoping mechanism to reason about. Pick one. Reach for @scope specifically when you are writing plain CSS or Sass without a build-time scoping tool and want the same guarantee natively, or when you are maintaining a large legacy stylesheet and want to carve out a component's styles without renaming every class.
It is also the wrong tool for truly global concerns like typography resets, color tokens defined as custom properties, or utility classes meant to apply everywhere. Scoping those just adds a layer of indirection with no payoff. Save @scope for the parts of your CSS that actually have collision risk: components that get reused, nested, or dropped into unpredictable contexts within the same page.
Testing it in your own project
Open DevTools on any page using @scope and inspect an element inside the scoped subtree. Chrome and Firefox both show the @scope (...) to (...) rule in the Styles pane exactly as written, and you can toggle it off to see what would have matched without the boundary. That toggle is the fastest way to confirm a leak is actually fixed rather than just visually coincidental.
A more reliable check is to duplicate the component deliberately, nest one inside another, and give the outer and inner instance visibly different content. If your scope-end boundary is correct, changing a style in the outer block should never touch the inner instance, and changing the inner instance should never bleed back out. Run that test once per component when you first add @scope, and again any time you change the scope-start or scope-end selectors, since a typo in either one silently widens or narrows the boundary without throwing any error.
Related Articles

CSS @when/@else: Writing Conditional Styles Without JavaScript
Testing three separate conditions today means three conditional blocks stitched together, or a JavaScript fallback filling the gaps. @when/@else isn't shipping yet, but it's worth planning for.

CSS Cascade Layers: Finally Tame Your Specificity Wars
Specificity wars used to mean raising your own selector weight or reaching for !important as a last resort. Cascade layers add a priority axis that sits above specificity entirely.

The :has() Selector Changes Everything: Real-World Patterns
CSS avoided parent selection for years over performance fears. WebKit broke the deadlock with Safari 15.4; here are the real-world :has() patterns worth using in production today.