@scope for Component Styles: Short Selectors That Cannot Leak

Component CSS has always had a reach problem. A rule written for a card's title — .title { font-size: 1.1rem } — applies to every .title on the page, so teams invented naming conventions such as BEM (.card__title) to make selectors unique, or reached for build tools that rewrite class names. Both work by avoiding the cascade's global reach rather than controlling it. @scope controls it directly: it limits a block of rules to a subtree of the document, and optionally stops at an inner boundary so a component's styles do not reach into content nested inside it. This page explains scope roots and limits, the new proximity step in the cascade, and where @scope fits beside layers and naming conventions. It belongs to Modern CSS Reset Strategies in the Mastering Container Queries & Responsive Layouts guide.

Why selectors need boundaries

There are two directions in which component styles leak. Outward: a short selector written for one component matches elements in another. Inward: a component's descendant selectors reach into content nested inside it — a card's p { margin-block: 0.5rem } restyling the paragraphs of a comment thread placed in the card's body.

Naming conventions prevent outward leaks by making every selector unique, at the cost of long class names everywhere. Nothing in plain CSS prevented inward leaks except more specific selectors or the child combinator, which breaks as soon as markup gains a wrapper. @scope solves both: the scope root prevents outward leaks, and the scope limit prevents inward ones.

A scope with a hole in it An outer page region, a card region marked as the scope root, and an inner slot region marked as the scope limit. The card's styles apply in the band between the card edge and the slot edge; they do not apply outside the card or inside the slot, where nested content keeps its own styles. @scope (.card) to (.card__slot) page: not styled by the card .card (scope root) card styles apply here .card__slot (scope limit) nested content keeps its own styles

The complete implementation

The card below uses short, generic selectors — h3, p, a — inside a scope, and its slot holds a nested comment component whose paragraphs are untouched by the card's rules.

Live demoDonut-scoped card styles that stop at the slot
The card styles its own p grey, but the comment in the slot and the paragraph outside the card keep their own styles. (In browsers without @scope the styled rules do not apply.)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@scope for components</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; margin: 1.5rem; }

  /* Card styles reach from .card down to, but not into, .card__slot. */
  @scope (.card) to (.card__slot) {
    :scope {                      /* the scope root itself */
      display: grid;
      gap: 0.5rem;
      padding: 1rem;
      border: 1px solid #cbd5e1;
      border-radius: 12px;
    }
    h3 { margin: 0; font-size: 1.1rem; }
    p  { margin: 0; color: #475569; }     /* short selector, cannot leak */
    a  { color: #4338ca; font-weight: 600; }
  }

  /* The comment component has its own scope. Its p is styled here, and
     the card's p rule never reaches it because of the scope limit. */
  @scope (.comment) {
    :scope { padding: 0.75rem; border-radius: 8px; background: #f1f5f9; }
    p { margin: 0; font-style: italic; color: #0f172a; }
  }
</style>
</head>
<body>
  <article class="card">
    <h3>Release notes</h3>
    <p>Version 4.2 adds offline sync.</p>
    <div class="card__slot">
      <div class="comment"><p>"Offline sync saved my trip." — a reader</p></div>
    </div>
    <a href="/releases/4-2/">Read more</a>
  </article>
  <p>Page paragraph: untouched by the card's p rule.</p>
</body>
</html>

Three things happen here that plain descendant selectors could not guarantee. The page-level paragraph outside the card is unaffected by the card's p rule — outward containment. The comment's paragraph inside the slot is unaffected by the card's p rule — inward containment. And the card's link and heading use bare element selectors without any naming scheme, because the scope makes them unambiguous.

The key technique: proximity joins the cascade

@scope adds a new step to how the cascade chooses between competing declarations. After origin, importance, layers and specificity are compared and found equal, scope proximity decides: the rule whose scope root is fewer generations away from the element wins. Only if proximity is also equal does source order break the tie.

Where proximity sits in the cascade Comparison steps in order: origin and importance, cascade layers, specificity, scope proximity, source order. Example: a link inside a dark theme scope nested inside a light theme scope takes the dark theme's colour because the dark scope root is closer. Ties are broken by the nearest scope root origin layers specificity proximity source order .theme-light .theme-dark a link here: dark scope is 1 step away → dark wins

Proximity is what makes nested themes and nested components behave intuitively. Two scoped theme blocks — @scope (.theme-light) { a { color: … } } and @scope (.theme-dark) { a { color: … } } — have identical specificity. Without proximity, whichever appeared later in the stylesheet would win everywhere, so a light section nested inside a dark one would get the wrong link colour. With proximity, each link takes its colour from the nearest theme root around it. The same problem is solved with custom properties and style queries in Container Style Query Theming; @scope solves it within plain selectors.

Note that proximity sits below specificity. A more specific selector in an outer scope still beats a less specific one in an inner scope. Keeping scoped selectors short and uniform — bare elements and single classes — lets proximity do its job.

Specificity inside a scope

Selectors inside @scope are written as if relative to the scope root, and their specificity is calculated from the selector as written — the scope root does not add weight. p inside @scope (.card) has the specificity of a bare element selector, one type selector, even though it only matches paragraphs within cards. That is a deliberate design choice: it lets scoped component rules stay light, so utilities and state classes override them easily.

The :scope pseudo-class refers to the scope root and carries the specificity of a pseudo-class, like a single class. Writing :scope > p targets only direct children of the root and weighs one class plus one type. If you want the scope root to contribute its full selector's weight — for example to match a legacy rule — write it out explicitly with & in the nested form, which behaves like :is(.card).

Two practical consequences follow. A scoped p rule loses to an unscoped .intro class anywhere on the page if they target the same element, because class beats type before proximity is considered; that is usually what you want, since a utility or state class should win. And two scoped rules for the same element from nested scopes with different selector weights are settled by weight first: a heavier selector in the outer scope beats a lighter one in the inner scope. Keeping scoped selectors uniformly light is what lets proximity make the decisions you expect.

Where @scope fits with layers and conventions

The three tools answer different questions, and they combine cleanly:

  • Cascade layers decide which group of styles wins — reset, tokens, components, utilities.
  • @scope decides which elements a group of styles can reach — this card, not the page; this card, not its slot.
  • Naming conventions make markup readable and searchable.card__slot still says what the element is for.

A practical arrangement puts each component's scoped rules inside the components layer: @layer components { @scope (.card) to (.card__slot) { … } }. The layer places the component relative to utilities and resets; the scope contains it relative to the rest of the page. Teams that adopt @scope often keep BEM-style names for elements that act as scope boundaries — .card__slot is clearer than an anonymous div — while dropping the long names for ordinary descendants.

Variation: inline <style> scoping

A <style> element placed inside a component's markup can use @scope with no selector, which scopes its rules to the style element's parent. This is useful for server-rendered components and CMS blocks that ship their own styles.

<section class="promo">
  <style>
    @scope {
      :scope { padding: 1.5rem; border-radius: 12px; background: #fef3c7; }
      h2 { margin: 0 0 0.5rem; }
      p  { margin: 0; }
    }
  </style>
  <h2>Spring sale</h2>
  <p>Twenty percent off all tents this week.</p>
</section>

The styles cannot escape the <section>, so a CMS editor can drop the block onto any page without its generic h2 and p rules affecting the rest of the page. The same property also makes the block's motion rules safe to include inline; for entrance animations on such blocks, see Starting-Style Entry Animations.

Browser support

@scope is supported in current Chromium-based browsers and in Safari; Firefox added support more recently, so check its current release status against your support policy. In an engine without @scope, the entire @scope block is ignored — its rules do not apply at all — so scoped styles need either a fallback or a support policy that excludes those engines. A common approach is to keep BEM-style class names in the scoped selectors during the transition (.card__title rather than h3), so the same rules can also be emitted unscoped for older browsers. Cascade layers are supported in Chrome and Edge 99+, Firefox 97+ and Safari 15.4+.

FAQ

What does @scope do? It restricts a block of style rules to elements inside a scope root, optionally stopping at a scope limit. Selectors inside the block only match elements within that range, so short selectors like .title cannot leak into unrelated parts of the page.

What is donut scoping? Scoping that has a hole in the middle. @scope (.card) to (.card__slot) styles everything inside .card except the contents of .card__slot, so a card's styles do not reach into content nested within it, such as another component placed in the slot.

How does scoping proximity affect the cascade? When two scoped rules with equal specificity and layer order both match an element, the one whose scope root is closer in the DOM wins. This lets nested themes or components override outer ones naturally, without raising specificity.

Does @scope replace cascade layers or BEM? It complements them. Layers decide precedence between groups of styles, @scope decides which elements a group can reach, and naming conventions remain useful for readability. Many teams use layers for ordering and @scope to keep component selectors short and contained.

Related articles

More pages in the same section.