EdgeCases Logo
Mar 2026
CSS
Surface
6 min read

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.

css
scope
css-scope
components
isolation
cascade

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 color on 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 + p won'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

Related Insights

Explore related edge cases and patterns

CSS
Deep
CSS Container Queries Edge Cases: When @container Silently Fails
7 min
CSS
Surface
CSS text-wrap: balance and pretty
6 min
CSS
Surface
CSS contain: Render Isolation for Performance
6 min

Advertisement