Anyone building components with their own CSS knows the pattern: a rule for the heading inside the card suddenly also hits the heading of another component that happens to sit inside the card. The usual answers are longer selectors, naming conventions such as BEM, or tools such as CSS Modules that rewrite class names. @scope solves the same problem in the browser. The rule marks out an area that starts at one element and, if you want, ends at another, and selectors inside it only apply within those bounds. When two scopes meet on the same element, the closer one wins at equal specificity. Chrome and Edge have supported @scope since version 118, Firefox since 146 in December 2025, and MDN has listed it as Baseline since March 2026. This guide shows the syntax, the donut scope, the role of proximity in the cascade, where @scope helps next to Tailwind and where it does not.
- @scope (.card) { ... } applies the rules inside only to elements within .card. Plain selectors such as h3 or img need no longer chain for that and get no extra specificity.
- With to (...) the area gets a lower bound. @scope (.card) to (.card-slot) leaves everything inside .card-slot alone, such as slotted content or other components. This is called a donut scope.
- When two scopes with equal specificity meet on the same element, the one whose root is closer wins. Proximity comes after specificity and before order of appearance in the cascade.
- @scope isolates nothing. Inherited properties such as color still flow across the bound, and a browser without support discards the whole block. Required styles therefore need a version that holds up without it.
Why do component styles overwrite each other?
Because a selector in CSS only says where something starts, not where it stops. .card h3 hits every level-three heading anywhere inside the card, including the one in a component that sits inside the card and brings its own rules. Which rule wins is then decided by specificity or order in the stylesheet, something that has nothing to do with intent.
The known answers all work around the problem. BEM requires every element to carry a class of its own with the component's name, so selectors never need nesting. CSS Modules and similar tools rewrite class names at build time so they become unique. Both work, and both move the work into names and tooling.
What was missing was a boundary in CSS itself: these rules apply here, and no longer there. That is exactly what @scope is.
A selector says where it starts. @scope also says where it stops.
How does @scope work?
You name the root of the area in parentheses, and every rule in the block applies only to the root and its descendants. Inside the block you write plain selectors such as h3, p or img, without the component's class in front. The root itself is reached with :scope.
The root does not count towards specificity. An h3 inside the scope is as strong as an h3 without one; MDN describes it as if :where(:scope) were in front. Only writing :scope explicitly adds its weight, that of a class. Selectors stay short and weak, and a class deliberately set on an element still prevails.
There is also a form without parentheses. A style element with @scope and no root applies to the style element's parent. That is handy for content inserted together with its CSS in one place, such as a single landing page section from a CMS.
@scope (.card) { :scope { border-radius: 16px; padding: 1.5rem; } h3 { font-size: 1.25rem; } img { border-radius: 12px; }}
What is a donut scope?
An area with a hole: it starts at the root and ends at a lower bound given with to. @scope (.card) to (.card-slot) applies to everything in the card except what sits inside .card-slot. The root is included, the lower bound itself is not.
That is exactly the case from the start. A card whose content receives other components can style its headings and images without touching the headings and images of the inserted components. The boundary lives in CSS, not in names, and the inserted component does not need to know anything about it.
The lower bound may be a whole list. If you know several components that should never be styled from outside, write them separated by commas in the second parentheses.
@scope (.card) to (.card-slot, .media-player) { img { aspect-ratio: 4 / 3; object-fit: cover; }}
- .card img also hits the images in every component sitting inside the card.
- To prevent that, a :not() is added, and specificity rises with it.
- The inserted component then needs stronger selectors of its own to prevail.
- img inside the scope only hits images up to the lower bound.
- Specificity stays that of a plain element.
- The inserted component styles itself without fighting the card.
Which scope wins when two meet on the same element?
At equal specificity, the one whose root is closer to the element. This proximity is a step of its own in the cascade. It comes after importance, layers and specificity, but before order in the stylesheet. Until now, order decided at this point, meaning which rule was written last.
The best-known example is nested colour schemes. A light panel inside a dark area, with a link inside: with ordinary selectors such as .dark a and .light a, whichever comes later in the stylesheet wins, no matter how the elements are nested. With two scopes the closer one wins, and the link in the light panel gets the light styling even though that panel sits in a dark area.
The order of the steps matters. Proximity only beats order, not specificity. A selector with one more class still wins, even from the farther scope. If you want to rely on proximity, keep selectors in both scopes equally strong.
@scope (.light) { a { color: #1d4ed8; }} @scope (.dark) { a { color: #93c5fd; }} <section class="dark"> <div class="light"> <a href="/pricing">See pricing</a> </div></section>
Where does @scope help next to Tailwind?
Where you cannot write classes on every element. For a component whose markup you write yourself, you usually do not need @scope, because a class like text-lg hits exactly the element it sits on. The problem of selectors reaching too far does not arise there at all.
Content arriving from outside is different. Running text from a CMS or from Markdown brings headings, lists and images without classes. Tailwind offers variants like [&_h2]:text-2xl on the container or a plugin for prose. A small stylesheet with @scope (.article-body) to (.embed) is often more readable, and the lower bound protects embedded components such as code blocks or videos from the text rules.
The second case is markup from libraries whose classes you cannot change. Instead of overriding their classes, you put a scope around the container and style the elements inside with plain selectors, without the rules causing trouble elsewhere on the page.
@scope (.article-body) to (.embed) { h2 { margin-block: 2.5rem 1rem; font-size: var(--text-2xl); } a { text-decoration-thickness: 1px; text-underline-offset: 3px; }}
Where does @scope reach its limits?
In three places, and all three come from @scope being a rule in the stylesheet and not a separation in the document. The first is inheritance. Properties such as color or font-family are still inherited by children, across the lower bound too. The donut scope only stops selectors from applying there, not inherited values from arriving there.
The second is the nesting selector &. MDN notes that browsers and versions treat it differently inside @scope, and the Chrome documentation credits it with the specificity of the root, while plain selectors get none. If you want reliable results, write plain selectors or :scope.
The third is the fallback. A browser without @scope discards the whole block. Unlike a new property that simply gets skipped, every rule inside is then missing. Since March 2026 that is no issue in current browsers, but it is for sites with many visitors on old devices. There the load-bearing rules belong in ordinary class selectors and the refinements in the scope.
- Inheritance keeps flowing. color, font-family and other inherited values also reach elements beyond the lower bound.
- & is unreliable. Inside @scope, write plain selectors or :scope, not &.
- Without support, everything is missing. The whole block is discarded. So write load-bearing rules outside the scope when old browsers matter.
@scope limits where rules apply. It does not limit what is inherited.






