CSS @scope: Native Style Scoping Without Shadow DOM
Native CSS scoping with upper AND lower boundaries — finally, component isolation without Shadow DOM or naming conventions.
BEM, CSS Modules, CSS-in-JS, Shadow DOM — we've invented countless workarounds for one problem:
keeping styles isolated to components. Now CSS has a native answer: @scope.
It lets you define exactly where styles apply (the root) and where they stop (the limit).
No build tools. No JavaScript. Just CSS.
Basic Syntax: Scope Root and Limit
/* Styles apply inside .card, but stop before .card-footer */
@scope (.card) to (.card-footer) {
p {
color: #333;
line-height: 1.6;
}
a {
color: var(--primary);
}
}
/* The footer has its own scope */
@scope (.card-footer) {
p {
color: #666;
font-size: 0.875rem;
}
}
The scope root (.card) is inclusive — styles apply to it and its descendants.
The scope limit (.card-footer) is exclusive — that element and its children
are outside the scope. This creates "donut scopes" where you can carve out nested regions.
The :scope Pseudo-Class
Inside a @scope block, :scope refers to the scope root element itself:
@scope (.card) {
:scope {
/* Styles the .card element directly */
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
:scope > h2 {
/* Direct children of the scope root */
margin-top: 0;
}
}
This is different from just writing .card — :scope only matches
the actual scope root element in context, not all .card elements on the page.
Inline Scopes: No Selectors Needed
When you place @scope inside a <style> tag within an element,
the scope root is automatically the parent element:
<article class="post">
<style>
@scope {
p { color: #333; }
a { color: blue; }
}
</style>
<p>This paragraph is scoped.</p>
<a href="#">This link too.</a>
</article>
<!-- These are NOT affected -->
<p>Outside the scope.</p>
You can still add a scope limit with inline scopes: @scope to (.exclude).
Scoping Proximity: A New Cascade Rule
Here's where @scope gets interesting. CSS now has scoping proximity
as a cascade criterion. When two scopes have conflicting styles, the one with the shorter
DOM distance to the scope root wins:
@scope (.light-theme) {
p { color: black; }
}
@scope (.dark-theme) {
p { color: white; }
}<div class="light-theme">
<p>Black text (1 hop to scope root)</p>
<div class="dark-theme">
<p>White text (1 hop to dark, 2 hops to light)</p>
<div class="light-theme">
<p>Black text again! (1 hop to inner light)</p>
</div>
</div>
</div>
Without @scope, the inner paragraph would be white because .dark-theme p
appears later in source order. With scoping proximity, the closest scope root wins regardless of
source order. This finally solves nested theming without specificity wars.
Specificity Gotcha: Selectors Inside @scope
Bare selectors inside @scope have zero added specificity from the scope:
@scope (.card) {
/* Specificity: 0-0-1 (just p) */
p { color: blue; }
/* Specificity: 0-0-1 (& acts like :where(:scope)) */
& p { color: blue; }
/* Specificity: 0-1-1 (:scope adds 0-1-0) */
:scope p { color: blue; }
}
Use :scope explicitly if you need specificity. Otherwise, scoped styles
can be overridden by any class-level selector outside the scope — which might be
intentional for allowing overrides, or a bug waiting to happen.
Practical Patterns
Component Isolation Without Build Tools
/* Component styles that won't leak */
@scope ([data-component="tabs"]) {
:scope {
display: flex;
gap: 0;
}
button {
border: none;
background: transparent;
padding: 0.5rem 1rem;
}
button[aria-selected="true"] {
border-bottom: 2px solid var(--primary);
}
}
/* Safe to use generic selectors */
@scope ([data-component="modal"]) {
h2 { margin: 0; }
p { margin: 0.5rem 0; }
button { min-width: 80px; }
}Excluding Nested Components
/* Style article content, but not embedded widgets */
@scope (article) to ([data-widget], .code-block, .embed) {
p {
font-family: Georgia, serif;
font-size: 1.125rem;
}
a {
color: var(--link-color);
text-decoration: underline;
}
/* Won't affect links inside widgets */
}Multiple Scope Roots
/* Apply same styles to multiple containers */
@scope (.sidebar, .drawer, [role="complementary"]) {
nav { padding: 1rem; }
a { display: block; }
}What @scope Doesn't Do
- No style isolation: Inherited properties still cascade through scope limits. If you set
coloron the scope root, children past the limit still inherit it. - No Shadow DOM encapsulation: External styles can still target scoped elements if selectors match.
- No selector escaping:
:scope + pwon't select siblings outside the scope — it's invalid.
Browser Support
As of 2026: Chrome 118+, Safari 17.4+, Firefox 146+. Edge follows Chrome.
Use @supports selector(:scope) inside @scope isn't valid —
use feature detection with CSS.supports('@scope (.a) { }') in JS, or
provide fallbacks using regular class selectors.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Related Insights
Explore related edge cases and patterns
Advertisement