Home / Articles / Modern CSS /

New Featurenew-featureselectors

@scope fundamentals: component-bound styling

The @scope at-rule binds a block of CSS to one DOM subtree, so a component can style its own elements without a naming convention holding the line.

The naming tax on component CSS

Scoping in CSS has always been a convention, not a mechanism. BEM, CSS Modules, and utility prefixes all exist because a selector like .card img matches every img anywhere inside a card — including one that belongs to an embedded video player, a comment widget, or a third-party ad. The usual fix is to make the selector more specific or more literal: .card__media-img. That works, but the cost is paid on every element, in every template, forever.

The alternative fix — deep descendant selectors like .card > .body > .media > img — trades naming discipline for DOM coupling. Change one wrapper and the rule stops matching. @scope removes the trade: you declare the boundary once, at the top of the block, and every selector inside it inherits that boundary.

The @scope syntax

An @scope rule takes a scope root selector in parentheses. Every style rule inside the block only matches elements within the subtree rooted at a matching element. The :scope pseudo-class refers to the root itself:

@scope (.card) {
  /* :scope is the .card element */
  :scope {
    display: grid;
    gap: 0.75rem;
    border: 1px solid oklch(0.9 0.01 260);
    border-radius: 0.75rem;
  }

  /* these only match inside a .card */
  img { inline-size: 100%; border-radius: 0.5rem; }
  h3  { font-size: 1.125rem; line-height: 1.3; }
  a   { color: oklch(0.55 0.18 265); }
}

Bare selectors such as img behave as though :where(:scope) were prepended to them. That is what makes them scoped, and it is also why they gain no extra specificity — more on that below.

Donut scope: cutting a hole in the subtree

The second half of the prelude is the scope limit, introduced with to. It marks where the scope stops. This is the pattern usually called donut scope: style everything inside a region except the parts that belong to something else.

/* Style prose, but stop at any embedded component */
@scope (.prose) to (.embed) {
  p { max-inline-size: 65ch; margin-block: 1em; }
  a { text-decoration: underline; text-underline-offset: 0.15em; }
}

The upper bound is inclusive and the lower bound is exclusive: the .prose element is in scope, and the .embed element is not — nor is anything inside it. If you want the boundary element itself styled but its contents left alone, move the boundary down one level with to (.embed > *). The mirror-image adjustment works on the root: @scope (.prose > *) excludes the root element while keeping its descendants.

Specificity stays flat

Scoping and specificity are deliberately separate. Because bare selectors get an implicit :where(:scope) prefix, and :where() contributes zero, the scope root adds nothing to the weight of the rule. Writing :scope explicitly is different — it is a pseudo-class, so it counts as one.

@scope (.card) {
  /* specificity 0,0,1 — same as a bare `a` selector */
  a { color: oklch(0.55 0.18 265); }

  /* specificity 0,1,1 — :scope counts as a pseudo-class */
  :scope a { color: oklch(0.5 0.2 30); }
}

This is the practical difference from the older workaround of nesting everything under a container class. .card a costs 0,1,1 and starts an escalation the moment a utility class needs to override it. The scoped bare selector costs 0,0,1, so a single-class utility still wins.

Scoping proximity, a new cascade step

@scope also adds a tiebreaker to the cascade. When two scoped rules of equal specificity both match an element, the winner is the one whose scope root is fewer hops up the DOM tree — scoping proximity. It sits below importance, layers, and specificity, and above source order.

/* Written first, but wins inside a nested dark region */
@scope (.theme-dark) {
  p { color: oklch(0.95 0.01 260); }
}

@scope (.theme-light) {
  p { color: oklch(0.25 0.02 260); }
}

Given .theme-light > .theme-dark > p, both rules match with specificity 0,0,1. Source order would hand the paragraph the light colour; proximity hands it the dark one, because .theme-dark is the nearer root. Nested themes stop needing override selectors.

Proximity is measured from the element to each matching scope root. It resolves nesting, not conflicts between unrelated scopes — those still fall back to source order.

Browser support and rollout strategy

@scope is supported in Chrome 118+, Safari 17.4+, and Firefox 146+. Firefox was the last engine to ship it, in December 2025, which is why the feature only reached Baseline "newly available" status at the end of that year. Treat it as usable but young: a browser one or two major versions behind is a realistic part of your traffic.

The failure mode matters more than the version numbers. A browser that does not understand @scope discards the entire at-rule block, not just the prelude — every declaration inside it disappears. That rules out putting layout-critical rules inside a scope while the feature is still settling.

  • Keep structural and typographic base styles outside @scope, in a cascade layer, so unsupported browsers still get a usable page.
  • Use @scope first where the payoff is highest: donut scoping around user-generated or third-party content.
  • Reach for a limit (to) before reaching for a more specific selector — the limit expresses intent, an extra class does not.
  • Do not use @scope to replace specificity management. It is orthogonal: scoping decides what matches, layers and specificity decide what wins.