CSS @scope: Style Encapsulation Without BEM or CSS Modules
A product card has a photo that should crop neatly to a 4:3 ratio. You write the simplest rule possible: .card img { aspect-ratio: 4 / 3; object-fit: cover; }. It works – right up until someone drops an author byline component into the card. The round 32×32 avatar suddenly becomes a 4:3 rectangle, because .card img has no idea that this particular image belongs to an entirely different component. A descendant selector matches every descendant – at any depth, inside any nested component.
For the last fifteen years, the answer to this problem has been to work around the cascade rather than use it. BEM turns .card img into .card__image and makes you add the class to every element by hand – the rule reaches exactly as far as someone remembered to label things. CSS Modules do the same thing automatically: .image becomes .Card_image__x7f2a, so a name collision becomes physically impossible. Both approaches work. Both boil down to the same idea: since a selector can't stop at a component boundary, make sure no other element shares its name.
@scope flips that around. Instead of unique names, it gives the selector something CSS has never had: an explicit boundary – on both sides.
Two boundaries, and BEM only gives you one
The .card img problem has two halves, and it's worth separating them, because most solutions only handle one.
The upper bound – the rule should apply only inside the card, not to every image on the page. A plain descendant selector already handled that: .card img won't touch the image in the site header.
The lower bound – the rule should stop applying where another component nested inside the card begins. A descendant selector has no way of expressing that, and that's exactly where the avatar bug lives. BEM and CSS Modules don't solve the lower bound – they sidestep it, because if the avatar has the class .author__avatar instead of .card__image, the card's rule simply has nothing to match. But when the card's content arrives from a CMS as raw HTML, from markdown, or as children passed in by someone else, you can't add the class – and you're back where you started.
@scope names both boundaries explicitly:
css
@scope (.card) to (.card__slot) {:scope {border: 1px solid #e0e0e0;border-radius: 16px;overflow: hidden;}img {width: 100%;aspect-ratio: 4 / 3;object-fit: cover;}}
The selector in the first parentheses is the scope root – the rules inside only apply to elements within .card. The selector after to is the scope limit – the .card__slot element and everything inside it is out of scope. The product image gets its 4:3 ratio, the avatar dropped into the slot stays untouched, and nobody had to add a single class to the image itself.
The syntax: root, limit and the "donut"
A scope with a hole in the middle is commonly called a donut scope – the styles cover the ring between the root and the limit, but not what's inside the donut. A few details that are easy to miss:
:scopeis the root itself. Inside@scope (.card), the:scopepseudo-class points to the.cardelement. That lets the container's styles and its contents' styles live in one block without repeating the class name.- Plain selectors inside are relative.
imgin a@scope (.card)block means "an image inside the root", not "any image on the page". You don't have to write:scope img, though you can – and in a moment it'll turn out that the difference isn't cosmetic. - The limit is relative to the root too. The selector after
tois only matched among the root's descendants, so the root itself will never be treated as a limit.
That last property enables a pattern that replaces an entire naming convention with one rule: every component marks its root with the same attribute, and every component stops at the first other component it encounters.
css
/* The card styles everything inside it – up to the firstnested component, whatever that component happens to be */@scope ([data-component="card"]) to ([data-component]) {img { aspect-ratio: 4 / 3; object-fit: cover; }p { color: #444; line-height: 1.6; }}@scope ([data-component="author"]) to ([data-component]) {img { width: 32px; height: 32px; border-radius: 50%; }}
html
<article data-component="card"><img src="/product.jpg" alt="Desk lamp"><p>Product description…</p><div data-component="author"><img src="/avatar.jpg" alt=""><p>Jane Doe</p></div></article>
The card's rules end at [data-component="author"], and the author's rules start there. Both components use bare img and p – and yet neither gets in the other's way.
Proximity: a new criterion in the cascade
So far @scope looks like a more convenient way to write a descendant selector with an exception. But it also brings something plain selectors don't have at all: scope proximity. When two rules from different scopes compete for the same element, the one whose root is closer in the DOM tree wins.
The classic example is nested themes. A light-themed panel inside a dark-themed page:
css
/* Without @scope */.theme-light a { color: #0b4fa8; }.theme-dark a { color: #8ab4ff; }
html
<body class="theme-dark"><aside class="theme-light"><a href="/terms">Terms of service</a></aside></body>
The link in the light panel gets the light-blue color meant for a dark background – which makes it nearly unreadable. Both selectors have identical specificity (one class, one element), so source order decides, and .theme-dark a comes later. The selector doesn't know that .theme-light is closer to the link than .theme-dark; it only knows that both are somewhere above it. Swapping the order in the file fixes this case and breaks the opposite one – a dark panel on a light page.
css
@scope (.theme-light) {a { color: #0b4fa8; }}@scope (.theme-dark) {a { color: #8ab4ff; }}
Now the link checks which root is closer: .theme-light is one generation up, .theme-dark is two. The light theme wins, regardless of rule order and however deeply you nest the themes inside each other.
The pitfall: proximity loses to specificity
This is where most introductions to @scope stay quiet, and some even get it backwards. In early drafts of the spec, proximity was supposed to resolve conflicts before specificity. In the version that shipped in browsers, it's the other way round – proximity applies only after specificity, as a tiebreaker just before source order. The full order of cascade criteria today looks like this:
- Origin and
!important - Context (Shadow DOM)
- Element-attached styles (the
styleattribute) - Layers (
@layer) - Specificity
- Scope proximity (
@scope) - Order of appearance
There's a second, less obvious detail on top of that: the scope root adds no specificity to the rules inside. img inside @scope (.card) has the specificity of a plain img – (0,0,1), not (0,1,1) like .card img. Together, these produce an effect that catches people out on their first migration:
css
/* Global article-content style – specificity (0,1,1) */.prose img {border-radius: 4px;}/* A card embedded in the article – specificity (0,0,1) */@scope (.card) {img {border-radius: 12px;}}/* Result: the image in the card gets 4px. Specificity settles itbefore proximity is ever compared. */
The card is closer to the image than .prose is, but that doesn't matter, because the contest never reaches the proximity stage. You have three ways out, each with a different effect:
:scope img– the:scopepseudo-class counts like a class, so specificity rises to (0,1,1). That's a tie with.prose img, and a tie goes to proximity – where the card always wins, because a rule with no@scopeat all is treated as infinitely far away.& img– inside@scope,&behaves like:is(.card), so it adds the full specificity of the root selector. With a simple root the effect is the same; with an elaborate one (#app .layout .card) it can add far more than you wanted.@layer– if article content styles live in a lower layer than components, specificity has no say here at all. That's the cleanest option if you already have the layer architecture from the@layerarticle.
The low specificity of rules inside @scope is actually more often a feature than a bug: a component styled with bare element selectors is easy to override from the outside with a single class. You just need to know that's how it works before you start debugging "why my scope doesn't work".
@scope without a selector: styles glued to the parent
There's a third form, probably the most underrated one. A @scope block with no selector at all, placed in a <style> element inside the document, takes the parent of that <style> element as its root.
tsx
export function PromoBanner({ title }: { title: string }) {return (<section><style>{`@scope {:scope { padding: 24px; background: #fff4e5; border-radius: 12px; }h2 { font-size: 1.25rem; margin: 0 0 8px; }a { color: #b45309; font-weight: 600; }}`}</style><h2>{title}</h2><a href="/deals">See the offer</a></section>)}
No class names, no build tool, no hashing – the scope is defined purely by where the style sits in the tree. For small, self-contained components, islands of HTML rendered from a CMS, or prototypes, it's a very convenient pattern.
Two caveats before you convert your whole project to it. First, the <style> renders with every instance of the component – ten banners on a page means ten copies of the same rules in the HTML. Second, in React 19 a <style> with the href and precedence props is moved into <head> and deduplicated. For ordinary styles that's a great feature, but for a selector-less @scope it's a silent disaster: the parent of the <style> becomes <head>, so the scope covers an element with nothing in it to style. Without those two props, React renders the <style> exactly where you put it, and everything works.
What @scope isn't: it's not Shadow DOM
It's easy to confuse @scope with the encapsulation Shadow DOM gives you, and the difference is fundamental. Shadow DOM is a boundary in both directions: outside styles don't get in, inside styles don't get out. @scope limits only one direction – how far your rules reach. The global .prose img from the example above still gets into the card unhindered, inheritance of color or font-family works as usual, and the reset in your reset layer applies to elements in scope just like it does to everything else.
That's a deliberate design decision, not a limitation. @scope is "soft" encapsulation for the regular DOM: it protects the rest of the page from a component's styles leaking out, but it doesn't cut the component off from the design system, typography and CSS variables it's supposed to inherit. If you need hard isolation – a widget embedded on other people's sites, where you don't control the global CSS – that's still a job for Shadow DOM.
Pitfalls and best practices
- Remember the order: layer, specificity, and only then proximity. Proximity only breaks ties. If a scope "doesn't work", check the specificity of the competing rule first – devtools show both the layer and the scope next to each rule.
@scopeand@layercomplement each other, they don't compete. Layers organize the architecture of the whole stylesheet (reset, libraries, components, utilities); scope organizes the inside of a single component. The natural combination is@layer components { @scope (.card) { … } }.- Choose deliberately between
img,:scope imgand& img. A bare selector gives the lowest specificity and easy overriding from outside,:scopeadds exactly one class,&adds the root's full specificity. - The lower bound is the place for slots and outside content. Wherever a component accepts
children, HTML from a CMS or another component,to (…)replaces adding classes to every element. - Watch out for style hoisting in React 19. A selector-less
@scopeneeds the<style>to stay in place – don't give ithreforprecedence. - Browser support. Chrome and Edge have supported
@scopesince version 118 (2023), Safari since 17.4 (2024), and Firefox joined last with version 146 at the end of 2025. An older browser will ignore the entire block along with the rules inside it – that's not graceful degradation, that's no styles at all. If you have to support older versions, stick with classes where it matters.
Summary
BEM and CSS Modules solve style leakage by changing names so the selector has nothing to match outside the component. @scope solves the same problem where it originates – in the selector itself – by giving it an upper bound, a lower bound, and a new criterion for resolving conflicts: proximity in the DOM tree. Keep in mind the two things that surprise people most: the scope root doesn't raise the specificity of the rules inside, and proximity loses to specificity, only getting a say in a tie. Know that, and you get components that write bare img and p and still stay out of each other's way – without a single double-underscore class.