CSS @scope: Complete Guide to Scoped Styles

Published on

CSS has always been global. Every selector competes in a single namespace, and any rule can accidentally affect elements it was never meant to touch. Developers invented BEM, CSS Modules, CSS-in-JS, and Shadow DOM to work around this problem. In 2024, browsers shipped a native solution: the @scope at-rule. It lets you confine styles to a DOM subtree, set hard boundaries, and leverage proximity-based specificity — all without build tools or JavaScript.

1. What Is @scope and Why Scoped Styles Matter

The @scope at-rule, defined in the CSS Cascading and Inheritance Level 6 specification, restricts selectors to a portion of the document tree. You declare a scoping root (where styles begin) and optionally a scoping limit (where styles stop). Everything between the root and the limit is the styled region.

Without scoping, every CSS rule applies globally. This causes three recurring problems in real projects:

The @scope rule solves all three natively. Selectors inside a scope block simply cannot match elements outside the defined region.

/* Styles only apply inside .card elements */
@scope (.card) {
  .title {
    font-size: 1.25rem;
    font-weight: 700;
  }
  .description {
    color: #9ca3af;
    line-height: 1.6;
  }
}

The .title selector above will never match a .title element outside a .card. No naming prefix needed.

2. Basic @scope Syntax and Usage

The basic form of @scope takes a selector as the scoping root:

@scope (root-selector) {
  /* These rules only apply within root-selector */
  selector { property: value; }
}

The root selector can be any valid CSS selector — a class, ID, element, attribute selector, or compound selector:

/* Scope to a class */
@scope (.sidebar) {
  a { color: #60a5fa; }
}

/* Scope to an attribute */
@scope ([data-theme="dark"]) {
  p { color: #e4e4e7; }
}

/* Scope to a compound selector */
@scope (main > .content) {
  h2 { border-bottom: 1px solid rgba(255,255,255,0.1); }
}

The :scope pseudo-class refers to the scoping root element itself. Use it when you need to style the root, not just its descendants:

@scope (.alert) {
  /* Style the .alert element itself */
  :scope {
    border: 1px solid var(--alert-color);
    border-radius: 8px;
    padding: 1rem 1.25rem;
  }
  /* Style descendants of .alert */
  p { margin: 0; }
  .icon { flex-shrink: 0; }
}

3. Scoping Roots and Limits (the to Keyword)

The full syntax of @scope includes an optional scoping limit using the to keyword:

@scope (root-selector) to (limit-selector) {
  /* Styles apply between root and limit */
}

The scoping limit defines where styles stop. Elements matching the limit selector, and all their descendants, are excluded from the scope.

@scope (.page) to (.widget) {
  /* Matches elements inside .page but NOT inside .widget */
  p {
    font-size: 1rem;
    line-height: 1.7;
  }
  a {
    color: #3b82f6;
    text-decoration: underline;
  }
}

Consider this HTML structure:

<div class="page">
  <p>This paragraph IS styled.</p>
  <div class="widget">
    <p>This paragraph is NOT styled.</p>
  </div>
  <p>This paragraph IS styled.</p>
</div>

The limit selector is evaluated relative to the scoping root. Multiple roots and limits can match in the document, and each creates its own scope region.

Multiple limit selectors

You can provide a comma-separated list of limit selectors:

/* Stop styling at .slot OR .nested-component */
@scope (.card) to (.slot, .nested-component) {
  color: #e4e4e7;
  font-family: system-ui, sans-serif;
}

4. The Donut Scope Pattern

The donut scope is the most powerful pattern enabled by @scope. When you set both a root and a limit, styles apply to the outer ring of a component but not its inner content. The styled region has a hole in the middle, like a donut.

/* Style the card's chrome, not its content slot */
@scope (.card) to (.card-body) {
  :scope {
    border: 1px solid rgba(255,255,255,0.1);
    border-radius: 12px;
    overflow: hidden;
  }
  .card-header {
    background: rgba(255,255,255,0.03);
    padding: 1rem 1.25rem;
    border-bottom: 1px solid rgba(255,255,255,0.08);
  }
  .card-footer {
    padding: 0.75rem 1.25rem;
    background: rgba(255,255,255,0.02);
  }
}

/* The card body gets its own scope */
@scope (.card-body) {
  :scope {
    padding: 1.25rem;
  }
  p { line-height: 1.7; }
}

This pattern is transformative for component architecture. A parent component can define its own frame — header, footer, border, spacing — without any styles leaking into nested child components. Each child manages its own styling independently.

<div class="card">
  <div class="card-header">Card Title</div>
  <div class="card-body">
    <!-- Inner content is excluded from .card's scope -->
    <div class="nested-widget">
      <p>This p is NOT affected by .card's styles</p>
    </div>
  </div>
  <div class="card-footer">Actions</div>
</div>

5. Proximity-Based Specificity

One of the most significant features of @scope is scoping proximity, a new factor in the CSS cascade. When two selectors have the same specificity and the same cascade layer, the selector whose @scope root is closer to the matched element in the DOM tree wins.

@scope (.light-theme) {
  a { color: #1e40af; }
}

@scope (.dark-theme) {
  a { color: #93c5fd; }
}
<div class="light-theme">
  <a href="#">Blue (light)</a>
  <div class="dark-theme">
    <a href="#">Light blue (dark wins - closer scope)</a>
  </div>
</div>

The inner <a> matches both scope rules. Both selectors are a with equal specificity. But .dark-theme is closer in the DOM tree to the matched anchor, so @scope (.dark-theme) wins. This is decided before source order — it is a new cascade step.

Cascade order with @scope

The full cascade priority (simplified) now includes proximity:

  1. Origin and importance (!important, user agent, author)
  2. Inline styles
  3. Cascade layers (@layer)
  4. Selector specificity
  5. Scoping proximity (new)
  6. Source order

Proximity only breaks ties when everything above it is equal. It does not override higher specificity or different layers.

/* Proximity makes nested theme overrides just work */
@scope (.theme-blue) {
  .btn { background: #3b82f6; }
}
@scope (.theme-green) {
  .btn { background: #22c55e; }
}
@scope (.theme-purple) {
  .btn { background: #a855f7; }
}

/* The closest ancestor theme always wins, no !important needed */

6. Inline @scope in Style Elements

You can use @scope without a root selector inside a <style> element. When you omit the root, the scope automatically attaches to the <style> element's parent:

<div class="widget">
  <style>
    @scope {
      /* Scoping root is .widget (the parent of this style tag) */
      :scope {
        border: 1px solid rgba(255,255,255,0.1);
        padding: 1rem;
      }
      p { color: #9ca3af; }
      a { color: #60a5fa; }
    }
  </style>
  <p>This paragraph is scoped.</p>
  <a href="#">This link is scoped.</a>
</div>

This is extremely useful for server-rendered components, CMS content blocks, and HTML partials. Each component carries its own styles that cannot leak out.

Inline scope with limits

<section class="article">
  <style>
    @scope to (.comments) {
      /* Styles apply to .article but stop at .comments */
      h2 { color: #f4f4f5; font-size: 1.5rem; }
      p { line-height: 1.8; color: #d4d4d8; }
    }
  </style>
  <h2>Article Title</h2>
  <p>Article body text is styled.</p>
  <div class="comments">
    <h2>Comments</h2>
    <p>This text is NOT styled by the scope above.</p>
  </div>
</section>

7. Nesting @scope Rules

You can nest @scope blocks inside each other to create layered component boundaries:

@scope (.dashboard) {
  :scope {
    display: grid;
    grid-template-columns: 250px 1fr;
    gap: 1.5rem;
  }

  @scope (.sidebar) {
    :scope {
      background: rgba(255,255,255,0.02);
      border-right: 1px solid rgba(255,255,255,0.08);
      padding: 1.5rem;
    }
    a {
      display: block;
      padding: 0.5rem 0.75rem;
      color: #9ca3af;
      border-radius: 6px;
    }
    a:hover {
      background: rgba(255,255,255,0.05);
      color: #e4e4e7;
    }
  }

  @scope (.main-content) {
    :scope { padding: 1.5rem; }
    h1 { font-size: 1.75rem; margin-bottom: 1rem; }
  }
}

Nested scopes are additive: the inner scope must still be within the outer scope's region. A nested @scope (.sidebar) inside @scope (.dashboard) only matches .sidebar elements that are descendants of .dashboard.

Nesting with limits at each level

@scope (.page) to (.section) {
  /* Page-level styles stop at sections */
  :scope { max-width: 1200px; margin: 0 auto; }

  @scope (.header) to (.nav) {
    /* Header styles stop at nav */
    :scope { padding: 1rem 2rem; }
    .logo { font-size: 1.5rem; font-weight: 700; }
  }
}

8. @scope with Shadow DOM

Shadow DOM and @scope solve different aspects of style isolation. Shadow DOM creates a hard boundary — no external styles can enter, and no internal styles can leave. @scope creates a soft boundary — styles are limited but can still be overridden by higher-specificity rules from outside.

/* @scope inside a shadow DOM component */
/* Used in a web component's adopted stylesheet */
@scope (:host) {
  :scope {
    display: block;
    border: 1px solid rgba(255,255,255,0.1);
    border-radius: 8px;
  }
  ::slotted(p) {
    margin: 0.5rem 0;
  }
}

When to use which

// Web component using @scope in its shadow root
class MyCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        @scope {
          :scope { display: block; padding: 1rem; }
          .header { font-weight: 700; margin-bottom: 0.5rem; }
          .body { color: #9ca3af; }
        }
      </style>
      <div class="header"><slot name="title"></slot></div>
      <div class="body"><slot></slot></div>
    `;
  }
}
customElements.define('my-card', MyCard);

9. Component Styling Patterns

Self-contained component

@scope (.modal) to (.modal-body) {
  :scope {
    position: fixed;
    inset: 0;
    display: grid;
    place-items: center;
    background: rgba(0,0,0,0.6);
    z-index: 1000;
  }
  .modal-dialog {
    background: #18181b;
    border: 1px solid rgba(255,255,255,0.1);
    border-radius: 12px;
    max-width: 500px;
    width: 90%;
  }
  .modal-header {
    padding: 1.25rem;
    border-bottom: 1px solid rgba(255,255,255,0.08);
    font-weight: 700;
    font-size: 1.1rem;
  }
  .modal-footer {
    padding: 1rem 1.25rem;
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
  }
}

Theme-aware component

@scope (.notification) {
  :scope {
    padding: 1rem 1.25rem;
    border-radius: 8px;
    border-left: 4px solid var(--notif-color, #3b82f6);
    background: var(--notif-bg, rgba(59,130,246,0.08));
  }
  :scope[data-type="success"] {
    --notif-color: #22c55e;
    --notif-bg: rgba(34,197,94,0.08);
  }
  :scope[data-type="error"] {
    --notif-color: #ef4444;
    --notif-bg: rgba(239,68,68,0.08);
  }
  :scope[data-type="warning"] {
    --notif-color: #f59e0b;
    --notif-bg: rgba(245,158,11,0.08);
  }
  .message { margin: 0; line-height: 1.5; }
  .dismiss {
    background: none;
    border: none;
    color: inherit;
    cursor: pointer;
    opacity: 0.6;
  }
  .dismiss:hover { opacity: 1; }
}

Slot-based composition

/* The layout component only styles its shell */
@scope (.split-layout) to (.split-pane) {
  :scope {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 2rem;
    min-height: 400px;
  }
}

/* Each pane manages its own styles independently */
@scope (.split-pane) {
  :scope {
    padding: 1.5rem;
    overflow-y: auto;
  }
  h3 { margin-bottom: 1rem; }
}

10. @scope vs BEM vs CSS Modules vs CSS-in-JS

Each approach to style isolation has tradeoffs. Here is a practical comparison:

Feature @scope BEM CSS Modules CSS-in-JS
Build tools needed No No Yes Yes
True isolation Yes (soft) Convention Yes Yes
Scoping limits Yes No No No
Proximity specificity Yes No No No
Runtime cost None None None Varies
Verbose class names No Yes Auto-generated Auto-generated
Works with SSR Yes Yes Yes Depends

BEM naming

/* BEM: naming convention prevents collision */
.card__title { font-size: 1.25rem; }
.card__description { color: #9ca3af; }
.card__footer { border-top: 1px solid #333; }

/* Works everywhere, but verbose and relies on discipline */

CSS Modules (compile-time)

/* card.module.css - class names get hashed at build time */
.title { font-size: 1.25rem; }
.description { color: #9ca3af; }

/* Output: .card_title_x7k2j { font-size: 1.25rem; } */

@scope (native)

/* No build step, no naming convention, native isolation */
@scope (.card) {
  .title { font-size: 1.25rem; }
  .description { color: #9ca3af; }
  .footer { border-top: 1px solid rgba(255,255,255,0.08); }
}

The key advantage of @scope over all alternatives is scoping limits and proximity. Neither BEM, CSS Modules, nor CSS-in-JS can prevent a parent from styling nested components. Only @scope with the to keyword provides this boundary natively.

11. Browser Support and Progressive Enhancement

As of February 2026, @scope support stands at:

This means @scope is supported in all major browsers and is considered Baseline Newly Available. For production use, consider progressive enhancement with @supports:

/* Progressive enhancement */
@supports (selector(:scope)) {
  @scope (.card) {
    .title { font-size: 1.25rem; }
  }
}

/* Fallback for older browsers using descendant selectors */
.card .title {
  font-size: 1.25rem;
}

Graceful degradation strategy

The best approach is to write your scoped styles first and provide a simpler fallback that works without scoping:

/* Fallback: works everywhere, slightly less isolated */
.card .title { font-size: 1.25rem; }
.card .body { line-height: 1.7; }

/* Enhancement: true scope for modern browsers */
@scope (.card) {
  .title { font-size: 1.25rem; }
  .body { line-height: 1.7; }
}

/* The scoped rules take precedence when supported.
   Older browsers use the fallback descendant selectors. */

12. Migration Strategies

From BEM to @scope

If you have existing BEM code, you can migrate incrementally. Start by wrapping existing selectors in scope blocks, then simplify the class names:

/* Step 1: Wrap existing BEM in @scope */
@scope (.card) {
  .card__title { font-size: 1.25rem; }
  .card__body { padding: 1rem; }
  .card__footer { border-top: 1px solid #333; }
}

/* Step 2: Simplify class names (BEM prefix no longer needed) */
@scope (.card) {
  .title { font-size: 1.25rem; }
  .body { padding: 1rem; }
  .footer { border-top: 1px solid #333; }
}

/* Step 3: Update HTML to use simpler classes */

From CSS Modules to @scope

/* Before: card.module.css */
.title { font-size: 1.25rem; }
.body { padding: 1rem; }

/* After: card.css (standard CSS file) */
@scope (.card) {
  .title { font-size: 1.25rem; }
  .body { padding: 1rem; }
}
/* HTML class names can now be human-readable instead of hashed */

Incremental adoption

You do not need to migrate your entire codebase at once. @scope coexists peacefully with BEM, CSS Modules, and any other strategy. Start with new components and migrate existing ones as you touch them.

/* Old BEM code still works */
.header__nav { display: flex; gap: 1rem; }

/* New component uses @scope */
@scope (.profile-card) {
  .avatar { border-radius: 50%; }
  .name { font-weight: 700; }
}

/* They coexist without conflict */

13. Real-World Use Cases and Patterns

CMS content isolation

Content management systems inject user-authored HTML. @scope prevents it from breaking page layout:

/* Page styles stop at the CMS content boundary */
@scope (.page-layout) to (.cms-content) {
  a { color: #3b82f6; text-decoration: none; }
  img { border-radius: 8px; }
}

/* CMS content gets its own safe scope */
@scope (.cms-content) {
  :scope {
    max-width: 65ch;
    line-height: 1.8;
  }
  img { max-width: 100%; height: auto; }
  a { color: #60a5fa; text-decoration: underline; }
  pre { background: #1a1a2e; padding: 1rem; border-radius: 6px; }
}

Third-party embed protection

/* Your styles stop at third-party embeds */
@scope (.app) to (iframe, .third-party-widget, [data-external]) {
  * { font-family: system-ui, sans-serif; }
  a { color: #3b82f6; }
}
/* Third-party content is untouched */

Email template components

<!-- Each email component carries its own scope -->
<div class="email-header">
  <style>
    @scope {
      :scope {
        background: #3b82f6;
        padding: 1.5rem;
        text-align: center;
      }
      h1 { color: white; margin: 0; font-size: 1.5rem; }
      p { color: rgba(255,255,255,0.8); margin: 0.5rem 0 0; }
    }
  </style>
  <h1>Welcome!</h1>
  <p>Thanks for signing up.</p>
</div>

Design system tokens with proximity

/* Base theme tokens */
@scope ([data-theme="base"]) {
  :scope {
    --color-surface: #18181b;
    --color-text: #e4e4e7;
    --color-accent: #3b82f6;
    --radius: 8px;
  }
}

/* Dense theme overrides via proximity, not specificity */
@scope ([data-theme="dense"]) {
  :scope {
    --color-surface: #09090b;
    --color-text: #d4d4d8;
    --color-accent: #60a5fa;
    --radius: 4px;
  }
}

/* Components use tokens — the closest theme ancestor wins */
@scope (.btn) {
  :scope {
    background: var(--color-accent);
    border-radius: var(--radius);
    color: white;
    padding: 0.5rem 1rem;
    border: none;
    cursor: pointer;
  }
}

Micro-frontend isolation

/* Each micro-frontend gets its own scope */
@scope (#team-a-app) to (#team-a-app [data-mfe]) {
  /* Team A's styles stay within their app */
  .header { background: #1e3a5f; }
  .sidebar { width: 250px; }
}

@scope (#team-b-app) to (#team-b-app [data-mfe]) {
  /* Team B's styles are completely independent */
  .header { background: #3f1e5f; }
  .sidebar { width: 300px; }
}
/* Both teams use .header and .sidebar without conflict */

Frequently Asked Questions

What is CSS @scope and how does it work?

CSS @scope is an at-rule that limits the reach of CSS selectors to a specific subtree of the DOM. You define a scoping root (the element where styles start applying) and optionally a scoping limit (the element where styles stop applying). Selectors inside an @scope block only match elements that are descendants of the scoping root and, if a limit is set, are not inside the scoping limit. This eliminates naming collisions and provides native component-scoped styling without build tools or JavaScript frameworks.

What is the donut scope pattern in CSS?

The donut scope pattern uses @scope with both a root and a limit to create a ring-shaped styling region. Styles apply to the area between the root and the limit, but not inside the limit. For example, @scope (.card) to (.card-content) styles the card's outer wrapper but excludes the inner content area. This is called a "donut" because the styled region has a hole in the middle. It is especially useful for styling a component's chrome — borders, padding, header — without affecting nested child components.

CSS @scope vs BEM vs CSS Modules: which should I use?

Choose based on your project constraints. BEM is a naming convention that works everywhere without tooling but relies on developer discipline and produces verbose class names. CSS Modules require a build step (webpack, Vite) and generate unique class names at compile time, guaranteeing isolation but coupling your CSS to a bundler. CSS @scope is a native browser feature that provides true selector scoping without build tools or naming conventions. Use @scope for new projects targeting modern browsers, CSS Modules when you need guaranteed isolation with older browser support, and BEM when you need the simplest zero-tooling approach or are working with legacy codebases.

Related Resources

📐 CSS Grid Complete Guide
Master two-dimensional layouts with CSS Grid
🎯 CSS :has() Selector
The parent selector CSS developers waited decades for
📦 CSS Container Queries
Responsive components based on parent size, not viewport