skip to content

In CSS, what does the optional to (...) limit on the @scope at-rule do, and why is a rule such as @scope (.card) to (.card-body) called donut scoping?

level: middleimportance: should knowfreq 30%

answer

  1. a subtree with a hole cut out
  2. descendant-or-self of root, minus limit subtrees
  3. the limit element itself is already out
  4. for slotted or foreign content
  5. repeat the root as the limit for nesting

basics

~20 s

The to (...) selector marks a lower boundary: rules inside the @scope block match the scope root and its descendants, but stop at any element matching the limit, that element included. The styled region is a subtree with a hole, hence donut.

solid answer

~50 s

`@scope (.card) { ... }` says the rules inside may only match `.card` and its descendants. Adding `to (.card-body)` cuts a hole out of that region: an element is in scope only if it is the scope root or a descendant of it, **and** is not a `.card-body` or a descendant of one. The limit element itself is excluded, not just its children. The result is a ring of styled elements around an unstyled interior — a donut — which is what you want when a component wraps content it does not own, such as slotted or user-authored markup, or a nested instance of the same component. A common idiom is repeating the root selector as the limit, `@scope (.card) to (.card)`, so an outer card stops styling at the nearest nested card and each instance is governed by its own scope. `@scope` shipped across Chrome, Safari and Firefox between late 2023 and 2024; older engines ignore the whole block, so treat it as progressive enhancement.

code

css · 5 lines
css
@scope (.card) to (.card-body) {
  :scope { border: 1px solid #ccc; padding: 1rem; }
  p { margin-block: 0.5rem; }
  a { color: rebeccapurple; }
}

go deeper

for a junior

Know that @scope confines a block of rules to one subtree and that the second parenthesised selector marks where they stop. Being able to read the syntax correctly is enough at this level.

for a middle

State the membership rule exactly — descendant-or-self of the root, minus any limit element and its descendants — and give a concrete reason the hole exists, such as content the component did not author.

for a senior

Show judgment about when scoping is worth it versus a naming convention already in place, and be explicit that unsupported browsers drop the whole block, so anything load-bearing needs a baseline underneath.

for a principal

Be ready to argue about adoption timing across a shared codebase: what a new at-rule costs in browser-support policy, how you would sequence migration off descendant-selector scoping, and how you would keep two mechanisms coexisting without confusion.

## The shape of the rule ```css @scope (.card) to (.card-body) { :scope { border: 1px solid #ccc; } p { margin-block: 0.5rem; } a { color: rebeccapurple; } } ``` The first parenthesised selector is the **scope root**. The optional `to (...)` selector is the **scope limit**. Both parts are ordinary selector lists — they are matched against the live document, not resolved once at parse time. Either part may be omitted: `@scope (.card) { ... }` has no lower boundary, and a prelude-less `@scope { ... }` inside a `<style>` element in the body scopes to that element's parent. ## The membership rule An element is in scope when both of these hold: - it is the scope root **or** a descendant of one, and - it is **not** a scope-limit element and **not** a descendant of one. The second clause is where people get caught. The limit is inclusive: the `.card-body` element itself is already out. If you want the boundary element styled but not its contents, put the boundary one level lower, or style it from a separate unscoped rule. Given this markup, with the block above: ```html <div class="card"> <p>styled</p> <div class="card-body"> <p>not styled</p> <a href="#">not styled</a> </div> <footer><p>styled</p></footer> </div> ``` The `.card` element itself is in scope — descendant-or-self includes the root — so `:scope { border: … }` applies. Everything from `.card-body` downward is outside. The `footer` sits beside the hole and is styled normally. That ring-with-a-hole geometry is the donut. ## Why the hole is the interesting part Before `@scope`, the natural way to scope a component's rules was a descendant selector: `.card p { … }`. That has no lower boundary at all. It styles every paragraph anywhere beneath `.card`, forever, including: - **content the component does not own** — markup passed in by a consumer, rendered from a CMS, or slotted in from elsewhere. A card should style its own chrome, not arbitrary prose someone drops inside it. - **nested instances of the same component**. `.card p` written for the outer card also hits paragraphs in a card nested inside it, so the inner instance inherits the outer instance's chrome. The second case has a neat idiom: ```css @scope (.card) to (.card) { .heading { font-size: 1.25rem; } } ``` The root stays in scope, and the first nested `.card` encountered on the way down acts as a limit — so an outer card's styles stop dead at an inner card, and the inner card is governed by its own instance of the same scope. Expressing this with plain descendant selectors requires either `:not()` gymnastics or a rule the language cannot state at all. ## What @scope does not do `@scope` is **one-way**. It constrains what the rules *inside* the block can match. It does nothing to stop rules elsewhere in the document from matching the same elements: a global `a { color: blue }` or someone's `.sidebar p` still applies. If you need a barrier in both directions, that is a shadow root, not an at-rule. It also does not stop inheritance. `color`, `font-family` and the other inherited properties still flow from an ancestor above the scope root into the scoped subtree, and straight across a scope limit into the hole — the hole is a matching boundary, not an inheritance boundary. That is usually what you want: the donut hole should look like the surrounding page. Finally, `@scope` is not a specificity trick. The prelude selectors constrain matching without adding to the specificity of the rules inside, which is a genuine advantage over the `.card p` form, but the cascade's other tiebreakers are unchanged. ## Support and fallback `@scope` reached Chrome and Edge in late 2023 and Safari and Firefox during 2024, so it is recent but shipping in all major engines. An engine that does not know the at-rule discards the whole block, contents included — the failure mode is *no styling*, not wrong styling. For anything load-bearing, ship a baseline that survives without the block; for chrome that is purely decorative, unconditional use is reasonable.

  • If the limit element itself is excluded, how would you style the boundary element but not its contents?
    Move the boundary down a level — limit to the element's children, for example `to (.card-body > *)` — or style the boundary element from a separate rule outside the `@scope` block. The membership rule is fixed: a scope-limit element and everything beneath it are out, with no opt-in for the boundary itself.
  • Does a scope limit stop inherited properties from reaching the elements inside the hole?
    No. A scope limit only decides which elements the block's selectors may match. Inherited properties such as `color` and `font-family`, and any custom properties set above, continue to flow down through the boundary from the ancestors. If you need the hole to reset, you have to declare values there explicitly.
  • What happens in a browser that does not support @scope?
    It does not recognise the at-rule, so it drops the entire block along with every rule inside it — the elements simply go unstyled rather than being styled wrongly. That makes `@scope` safe as progressive enhancement for decoration, but risky for layout-critical rules unless a baseline exists without it.

saying these in an interview costs you the question

  • Thinks the limit element is styled and only its descendants are excluded
  • Believes @scope also blocks outside selectors from matching inside
  • Says the scope root itself is never styled by the block
  • Thinks a scope limit stops inheritance from crossing it
  • Assumes an unsupported @scope block degrades to unscoped rules

context