← blog
CSSJuly 31, 2026 · 5 min read

CSS has no native scoping. @scope changes that.

BEM, CSS Modules, and scoped component styles all exist because CSS leaks — a rule written for one component can silently affect another. `@scope` gives the cascade a lower boundary so styles stay where you put them, natively, with no build step.

Parsa Jiravand · Frontend engineer · building bestpractic
CSS has no native scoping. `@scope` changes that.

Every CSS naming convention — BEM, SMACSS, ITCSS — exists to solve the same problem: CSS has no scoping. A rule you write for one component will apply to any matching element anywhere on the page. The solution people reach for is either a disciplined naming scheme (card__title) or a build-time tool that rewrites selectors into hashed strings (CSS Modules, CSS-in-JS). Both work. Neither is the platform doing it for you.

@scope is the platform doing it for you. It shipped in Chrome 118, Firefox 128, and Safari 17.4 — broadly available, no polyfill needed.

@scope takes a scoping root — a selector that marks the boundary of a subtree — and an optional scoping limit — a selector that marks where the scope stops. Rules written inside the block apply only to elements that are descendants of the root and not also descendants of the limit.

The basic form:

CSS
1
2
3
4
5
6
7
8
9
10
@scope (.card) { .title { font-size: 1.25rem; font-weight: 600; } .body { color: oklch(40% 0 0); } }

.title here only matches elements with class="title" that are inside a .card. A .title outside any .card, or inside a .card nested inside another component, is unaffected. You don't need to write .card .title — the selector is already implicitly scoped.

The scoping limit is what makes @scope genuinely different from just using a descendant selector.

Imagine a card component that can contain a testimonial sub-component. The testimonial has its own .title. You want the card styles to not leak into the testimonial.

CSS
1
2
3
4
5
@scope (.card) to (.testimonial) { .title { font-size: 1.25rem; } }

Now .title matches only when it is:

  • inside a .card, and
  • not also inside a .testimonial that is itself inside that .card

Without the lower boundary, a descendant selector would match every .title in the whole subtree, including the testimonial's. The closest you can get with a plain selector is bolting on an escape hatch — .card .title:not(.testimonial *) — and then repeating it, correctly, on every rule in the component. @scope states the boundary once in the prelude and every rule in the block inherits it.

Runs right in your browser — poke at it and watch the concept react live.

Here is a typical card written in BEM:

CSS
1
2
3
4
5
.card { } .card__header { } .card__title { font-size: 1.25rem; } .card__body { color: #555; } .card__footer { border-top: 1px solid #e5e7eb; }

The same component with @scope:

CSS
1
2
3
4
5
6
@scope (.card) { .header { } .title { font-size: 1.25rem; } .body { color: oklch(40% 0 0); } .footer { border-top: 1px solid oklch(92% 0 0); } }

The class names in your HTML become .header, .title, .body, .footer — not .card__header, .card__title. The naming convention was the documentation for the scoping relationship; @scope makes that relationship structural instead.

There is a subtlety worth knowing, and it's the one most @scope explainers get backwards: when two @scope rules match the same element, the one whose scope root is closest to the element wins — provided their specificity is equal.

CSS
1
2
3
4
5
6
7
@scope (.page) { .button { background: blue; } } @scope (.sidebar) { .button { background: green; } }
HTML
1
2
3
4
5
<div class="page"> <div class="sidebar"> <button class="button">Click me</button> </div> </div>

The button gets a green background. Both rules are specificity 0,1,0, so the tie falls through to scope proximity, and .sidebar is closer to the button than .page is.

Now change one thing:

CSS
1
2
3
4
5
6
7
@scope (.page) { .button.button.button { background: blue; } /* 0,3,0 */ } @scope (.sidebar) { .button { background: green; } /* 0,1,0 */ }

The button turns blue. Scope proximity sits below specificity in the cascade — Cascade 6 sorts by origin, context, element-attached styles, layers, specificity, then proximity, then order of appearance. So a more specific selector in a distant scope still beats a less specific one nearby. Proximity is a tiebreaker, not a trump card.

The practical version: inside a component library where every rule is a single class, proximity does the intuitive thing and local styles win. The moment you start stacking selectors for specificity, you're back to the old rules.

@scope also works without a root argument inside a <style> element. In that form it scopes to the style element's own parent element — which is useful in web components or template-based systems where each template has its own embedded styles:

HTML
1
2
3
4
5
6
7
8
9
10
<article class="post"> <style> @scope { h2 { font-size: 1.5rem; } p { line-height: 1.7; } } </style> <h2>Post title</h2> <p>Body text.</p> </article>

The h2 and p rules here only apply inside this <article>. Any other h2 or p on the page is untouched.

@scope is Baseline 2024: Chrome 118 (October 2023), Firefox 128 (July 2024), Safari 17.4 (March 2024).

Older browsers silently ignore the @scope block — rules inside it do not apply. For a graceful fallback, write your standard scoped selectors first and let @scope be a progressive enhancement:

CSS
1
2
3
4
5
6
7
/* fallback: works everywhere */ .card .title { font-size: 1.25rem; } /* enhancement: better proximity behavior, cleaner selectors */ @scope (.card) { .title { font-size: 1.25rem; } }

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

@scope is not a replacement for every CSS architecture decision — specificity, cascade layers, and custom properties all still apply. What it replaces is the naming convention as a scoping mechanism. If your CSS class names carry double underscores or component prefixes solely to prevent leakage, that's the problem @scope solves.

Look for the places in your stylesheet where every rule starts with the same parent selector: .card .x, .card .y, .card .z. Wrap them in @scope (.card) { ... } and drop the parent prefix from each inner rule. Add a lower boundary to any scope that contains nested components. The cascade still works — you're just giving it better information about where rules are meant to apply.


Thanks for reading! Let's stay connected:

Originally published on dev.to