Stack Patterns ■ Episode 13
Every web developer has fought this particular fight. You write a perfectly innocent rule:
.card h3 { font-weight: 600; }
Then a third-party widget on the same page turns out to have
a .card of its own, with an h3, and
your bold heading is now in animated negotiation with
someone else's typography. You add a class. Then a more
specific class. Then you wrap your component in a unique
parent selector. By the end of the year you have either
adopted BEM and renamed every selector to
.product-card__heading--featured, installed
CSS Modules
and accepted a build step, or surrendered to 80 KB of
styled-components. CSS, in its capacity as a global
namespace, has won again.
@scope is the native CSS answer, and, as of December 2025, it works in every modern browser.
A Brief, Slightly Embarrassing History
The idea is rather older than most of the people currently
enthusiastic about it. In 2013, the original
CSS Scoping Module Level 1
included a <style scoped> HTML attribute
and an @scope block. The plan was simple:
declare a region of the DOM, scope styles to it. It was
never implemented. By 2018, CSS Cascade Level 4 quietly
removed scoping from the cascade sort criteria altogether,
with the polite editorial note that nobody had built it.
The community drifted towards Shadow DOM (which solved a
different, stricter problem) and towards JavaScript-based
scoping libraries (which solved the original problem at
the cost of a build step and a runtime). One does note
the asymmetry.
The current @scope rule is, in effect, the
second attempt. The
explainer
was written by
Miriam Suzanne at Oddbird
and driven through the CSS Working Group with input from
Google, Mozilla, and Apple.
Chrome shipped it in version 118 (October 2023),
Safari in 17.4 (March 2024), and Firefox in 146 in
December 2025, at which point it reached "Newly available"
Baseline status across the board. Global usage is now just
over 91 per cent. In total, from the first draft, twenty-five
years.
The Pattern
The standalone form takes a scope root and an optional scope limit:
@scope (.card) to (.embedded-card) {
img { border-radius: 0.5rem; }
h3 { font-weight: 600; }
}
(.card) is the upper bound, where the scope
begins; .embedded-card is the lower bound, where
it ends. Rules apply to elements between the two boundaries,
but never to elements inside an .embedded-card.
The pattern has a rather charming nickname: donut
scope. Style the doughnut, leave the hole alone. The
hole is wherever a nested component takes responsibility for
its own styling, and you politely stop interfering.
When the stylesheet lives inside the component itself, the prelude can be omitted entirely, and things become rather pleasantly compact:
<article-card>
<style>
@scope {
:scope { padding: 1rem; border: 1px solid #ddd; }
h3 { font-weight: 600; margin: 0 0 0.5rem; }
p { color: #555; line-height: 1.5; }
}
</style>
<h3>Title</h3>
<p>Body copy.</p>
</article-card>
The implicit scope root is the <style>
element's parent. The :scope pseudo-class refers
to that root, so :scope matches the
<article-card> itself. Drop the markup
anywhere on the page and the rules travel with it, scoped
to exactly where they should be. This is, conceptually, what
styled-components and Vue's scoped CSS have
been doing for a decade, except styled-components costs
25 KB and a Babel transform, and the browser version costs
zero. One has, in two lines of CSS, reinvented an entire
library category, and one did not have to install Babel.
Why It Works
The detail that makes @scope properly elegant is
what it deliberately does not do. The scope root
contributes nothing to specificity. A bare h3
inside @scope (.card) has specificity
(0,0,1), exactly the same as a bare
h3 outside any scope. The
CSS Cascade Level 6 Working Draft
describes this as the bare selectors behaving as if
:where(:scope) were prepended, and
:where() is the pseudo-class that adds
precisely zero specificity.
This sounds like a small thing. It is, in fact, the entire
reason CSS-in-JS exists. Every framework that hashes class
names is solving two problems at once: scoping the rules so
they only apply where intended, AND keeping specificity low
so future overrides remain possible without an
!important arms race. @scope
solves both at once, by changing the cascade rather than
the markup. One does, on reflection, find this a rather
handsome piece of spec-writing.
The cascade itself gains a new sorting tier:
scoping proximity. The revised cascade order, per
Cascade Level 6, reads roughly: importance first
(!important), then cascade layers
(@layer), then specificity, then scoping
proximity, then source order. When two @scope
blocks produce conflicting declarations for the same
element, the rule whose root is fewer DOM hops away from
that element wins.
Which makes nested theme switching a quiet pleasure:
@scope (.dark) { p { color: white; } }
@scope (.light) { p { color: black; } }
A .light wrapper inside a .dark
wrapper produces light paragraphs. A .dark
wrapper inside a .light wrapper produces dark
ones. Nest as deeply as you like; the closest ancestor with
a matching scope wins. There is no JavaScript, no media
query stack, no design token lookup. The DOM tree is the
lookup.
The Old Ways, Priced Out
Honest Limitations
Two things worth knowing before one ships
@scope to production, in the spirit of not
selling things one does not entirely own.
First, the & selector inside
@scope blocks has had subtle interoperability
differences across engines, particularly in earlier
versions. The behaviour is now well-specified
(& selector desugars through
:is() and inherits its specificity rules), but
if one targets browsers older than late 2024, prefer
explicit selectors over & for the moment.
It will come right; it has come right; it just wasn't,
briefly, right everywhere at once.
Second, and more philosophically:
@scope is style scoping, not full
encapsulation. Your scoped styles do not leak out, but the
global cascade still leaks in. A high-specificity rule from
elsewhere on the page can still override your scoped rule.
This is a feature, not a bug: it means design system tokens,
accessibility overrides, and user stylesheets all continue
to work as intended. If one needs true encapsulation, where
outside CSS cannot reach in either, Shadow DOM still has a
job. Different problem, different tool.
When to Use
Anywhere one currently uses BEM naming to fake scoping.
Anywhere one reaches for CSS Modules. Anywhere one
considered styled-components or Emotion for the third time
today. Anywhere a third-party widget's stylesheet leaks
into one's own, and one would rather solve that with a
one-line @scope boundary than a class-name
renaming campaign.
For a fresh design system, build it in scopes from the
start. Each component declares its @scope
block; the global stylesheet handles tokens and base
typography in unscoped form. The result is a CSS file one
can read top-to-bottom and reason about with no surprises,
which is, after twenty-five years of the genre, a rather
novel experience.
The Cascade, Civilised
Episode 03 of this
series tackled @layer: the rule that decided
which stylesheet's voice prevails when several try to speak
at once. @scope decides where each voice may
speak in the first place. Between them, they cover the two
great unsolved problems of CSS architecture: ordering and
bounding. Both have now been quietly solved by reading the
spec, rather than by installing the next generation of
build tools.
The cascade, after twenty-five years, is finally finished.
@scope: native, 91% support since December 2025. Scope root adds zero specificity. "Donut scope": style the doughnut, leave the hole alone. New cascade tier: scoping proximity. Nested theme switching in two lines of CSS. Replaces BEM, CSS Modules, styled-components (~25 KB + Babel). Style-in-JS finally answered by the browser.