DEV Community

Cover image for CSS has no native scoping. `@scope` changes that.
Parsa Jiravand
Parsa Jiravand

Posted on

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.

What @scope does

@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:

@scope (.card) {
  .title {
    font-size: 1.25rem;
    font-weight: 600;
  }

  .body {
    color: oklch(40% 0 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

.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 lower boundary: the part most tutorials skip

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.

@scope (.card) to (.testimonial) {
  .title {
    font-size: 1.25rem;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

🎮 Try it yourself

▶️ Open the interactive playground →

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

Replacing a BEM naming pattern

Here is a typical card written in BEM:

.card { }
.card__header { }
.card__title { font-size: 1.25rem; }
.card__body { color: #555; }
.card__footer { border-top: 1px solid #e5e7eb; }
Enter fullscreen mode Exit fullscreen mode

The same component with @scope:

@scope (.card) {
  .header { }
  .title { font-size: 1.25rem; }
  .body { color: oklch(40% 0 0); }
  .footer { border-top: 1px solid oklch(92% 0 0); }
}
Enter fullscreen mode Exit fullscreen mode

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.

Proximity breaks ties

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.

@scope (.page) {
  .button { background: blue; }
}

@scope (.sidebar) {
  .button { background: green; }
}
Enter fullscreen mode Exit fullscreen mode
<div class="page">
  <div class="sidebar">
    <button class="button">Click me</button>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

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:

@scope (.page) {
  .button.button.button { background: blue; }  /* 0,3,0 */
}

@scope (.sidebar) {
  .button { background: green; }               /* 0,1,0 */
}
Enter fullscreen mode Exit fullscreen mode

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.

Inline scoping with @scope in <style> blocks

@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:

<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>
Enter fullscreen mode Exit fullscreen mode

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

Browser support

@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:

/* fallback: works everywhere */
.card .title { font-size: 1.25rem; }

/* enhancement: better proximity behavior, cleaner selectors */
@scope (.card) {
  .title { font-size: 1.25rem; }
}
Enter fullscreen mode Exit fullscreen mode

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

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

The takeaway

@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:

Top comments (0)