Your Component Styles Are Leaking: CSS @scope and the Donut Scope Fix

By Syed · Frontend Engineer & Creator of UITweak

October 2026 · 8 min read

Every component library eventually ships this bug. You write .card h2 to style the card's own heading, and months later someone drops a nested component inside the card, an accordion, a tooltip, anything with its own h2, and your rule reaches in and restyles it. The fix was always more naming: longer class names, BEM modifiers, CSS Modules, Shadow DOM. All of them manage the leak with tooling or discipline. CSS now has a native way to simply stop the leak, and it can express something naming never could: a lower boundary.

30-second version: @scope confines a block of rules to a DOM subtree. @scope (.card) to (.card-slot) { ... } styles everything inside .card but stops at .card-slot and below, a donut shape with a hole. No BEM, no build step, no JavaScript. Baseline 2025 in Chrome 118+, Edge 118+, Safari 17.4+ and Firefox 146+.

The leaking-component problem

Selectors have no concept of "mine, but not my children's". This markup shows why:

<div class="card">
  <h2>Card title</h2>
  <div class="tooltip">
    <h2>Tooltip title</h2> <!-- .card h2 styles this too. Oops. -->
  </div>
</div>

The traditional answers all push the cost somewhere else. BEM demands perfect naming discipline from every contributor forever. CSS Modules demand a build step and hashed class names in your HTML. Shadow DOM demands giving up the cascade entirely, which breaks theming. None of them can say what you actually mean: "style the card's subtree, but stop where nested components begin."

@scope basics: an upper boundary

@scope takes a scope root, and every selector inside the block only matches elements within that root's subtree:

@scope (.card) {
  h2 {
    font-size: 1.25rem;
    letter-spacing: -0.01em;
  }
}

Inside the block, :scope refers to the root element itself, so you can style the card and its contents in one place:

@scope (.card) {
  :scope {
    border: 1px solid #e2e8f0;
    border-radius: 1rem;
    padding: 1.5rem;
  }
  h2 { font-size: 1.25rem; }
}

The donut scope: a lower boundary too

This is the part naming conventions cannot do. Adding to (...)sets a scope limit: rules match elements inside the root but not inside the limit or below it. The matched region is a donut, with a hole where nested components live:

@scope (.card) to (.card-slot) {
  /* styles .card and its descendants,
     EXCEPT anything inside .card-slot */
  h2 { font-size: 1.25rem; }
  img { border-radius: 0.5rem; }
}

The tooltip's h2 from the earlier example is now safe, as long as nested components render inside a slot element. Slot the boundary where your component's responsibility ends and someone else's begins. This is the entire BEM-vs-leak debate, resolved in one line of CSS with no naming ceremony.

Nested themes resolve by proximity, not order

@scope quietly adds a new cascade tiebreaker: scoping proximity. When two scoped rules have equal specificity, the one whose scope root is closer to the element in the DOM wins. It sits between specificity and source order.

This is the theming case that used to need JavaScript or specificity hacks: a dark card nested inside a light page, or a compact widget inside a roomy dashboard. Each scope declares its own theme, and the closest one wins automatically:

@scope (.theme-light) {
  :scope { --surface: #ffffff; --ink: #0f172a; }
}
@scope (.theme-dark) {
  :scope { --surface: #0f172a; --ink: #f8fafc; }
}
/* .theme-dark nested inside .theme-light:
   proximity picks dark. No !important, no JS. */

@scope vs nesting vs BEM: when each wins

  • Reach for @scope when you own a component subtree and want its styles contained with a real boundary, especially with slots for nested content. It is the only one of the three that can say "stop here".
  • Reach for native nesting when you just want readable, grouped selectors and the cascade can stay global. Nesting organizes rules; it does not contain them.
  • Keep BEM when you ship to browsers older than the Baseline set, or when your team already runs on it and the migration cost outweighs the win. BEM is a convention that works everywhere; @scope is a platform feature that works in modern browsers.

One more pairing worth knowing: layers and scope solve different halves of the cascade. Our Tailwind v4 @layer guide covers how @layer controls priority, which rule wins. @scope controls reach, where a rule is even allowed to match. Use them together and both halves are explicit.

Scope a button's internals with the button studio

Here is a concrete place to try it. Buttons are small components with internal parts, an icon, a label, a loading spinner, that you style once and reuse inside cards, modals and navbars. Scope the button's internals so the card's typography rules can never reach in:

@scope (.btn) to (.btn-portal) {
  :scope {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
  }
  svg { width: 1rem; height: 1rem; }
  .spinner { animation: spin 0.8s linear infinite; }
}

Build the button itself visually first: uitweak's Tailwind button studio generates the variants, sizes and states. Then wrap the output in a @scope block like the one above, and the button becomes a sealed unit: drop it anywhere and its icon and spinner styles travel with it, immune to whatever typography the parent page declares.

The fallback: it degrades to unscoped

Browsers that do not understand @scope ignore the whole at-rule block, which means the rules inside simply do not apply. That is a graceful degradation, not a breakage, but plan for it: keep your selectors sane outside the scope too, so the unscoped fallback still looks acceptable. When you need certainty, feature-detect it:

/* baseline styles for everyone */
.card h2 { font-size: 1.25rem; }

/* tighter scoping where supported */
@supports at-rule(@scope) {
  @scope (.card) to (.card-slot) {
    h2 { font-size: 1.25rem; }
  }
}

Browser support

@scope is Baseline 2025 (Newly Available since December 2025) across all engines: Chrome 118+, Edge 118+, Opera 106+, Samsung Internet 25+, Safari 17.4+ and Firefox 146+. Firefox 146 is the version where it shipped enabled by default. That coverage makes it safe for component libraries and design systems today, with the @supports at-rule(@scope) check above for anything that must also serve older browsers.

  • Chrome / Edge: 118+
  • Safari: 17.4+
  • Firefox: 146+ (enabled by default)
  • Opera: 106+
  • Samsung Internet: 25+

One specificity detail worth knowing: scoped selectors behave as if prefixed with :where(:scope), which carries class-level specificity (0-1-0). Proximity then breaks ties between scoped rules before source order is consulted.

FAQ

Does @scope replace BEM entirely?

Not entirely. It replaces the leak-prevention half of BEM for modern browsers, and the donut scope does something BEM cannot. But BEM still works everywhere, including legacy browsers, and teams with an existing convention may not gain enough to migrate. Treat @scope as the default for new components, BEM as the fallback story.

What is the difference between @scope and CSS nesting?

Nesting groups selectors under a parent for readability; the compiled rules still match globally. @scope changes which elements the selectors are allowed to match at all, and adds the proximity tiebreaker. They compose well: nest inside a scope block for readable, contained rules.

Can a prelude-less @scope scope itself?

Inside an inline <style> element, a prelude-less @scope { ... } block scopes its rules to the style element's parent. In an external stylesheet there is no parent to bind to, so it applies nothing. In practice, always write the prelude explicitly.

Share this guide