Writing · Article

CSS @scope Is Baseline: A Practical Guide for React Developers

@scope became Baseline in March 2026. What it actually solves, how scoping roots and limits work, the donut problem, and whether it replaces CSS Modules in a React app.

Scoping CSS to a component has been a solved problem in React for a decade — just not by CSS. We solved it with build tooling: CSS Modules hashing class names, styled-components generating them at runtime, Tailwind sidestepping the question entirely by never writing component CSS in the first place.

As of March 2026, CSS can do it natively. @scope reached Baseline newly-available status when Firefox 146 shipped support, joining Chrome, Edge and Safari. That makes it worth a serious look rather than a bookmark.

This is a practical walkthrough: what the syntax does, the one genuinely novel capability it has that no preprocessor could give you, the proximity rule that will surprise you, and an honest answer to whether you should replace CSS Modules with it in a React codebase.

The basic form

@scope takes a scoping root — a selector identifying the element where the styles begin to apply — and everything inside the block matches only within that subtree.

@scope (.card) {
  img {
    border-radius: 8px;
    aspect-ratio: 16 / 9;
  }

  h2 {
    font-size: 1.25rem;
    margin-block: 0 0.5rem;
  }
}

Those bare img and h2 selectors only match inside a .card. No prefix, no class on the child, no BEM. If you have ever written .card__title purely so the rule would not leak, this is the feature that deletes that naming convention.

Inside a scope, :scope refers to the root element itself, and a leading & works the way nesting taught you to expect:

@scope (.card) {
  :scope {
    display: grid;
    gap: 0.75rem;
    padding: 1rem;
  }

  & > footer {
    border-top: 1px solid canvastext;
  }
}

The part that is genuinely new: scoping limits

The second argument is where @scope does something no preprocessor ever could. A scoping limit defines where the scope stops — creating a shaped region rather than a whole subtree.

@scope (.article) to (.comments) {
  a {
    color: rebeccapurple;
    text-underline-offset: 2px;
  }
}

Links inside .article are styled. Links inside .comments, which is nested within the article, are not. The scope applies from the root down and switches off at the limit boundary.

This is the so-called donut scope, and it is worth dwelling on because it solves a problem that was previously genuinely awkward. Think of any component that renders content it does not own — a layout wrapping a CMS body, a comment thread inside an article, a slot rendering arbitrary children, a third-party embed. The old solution was a defensive cascade of :not() selectors or a reset class, both of which were fragile in different ways.

/* Style the shell, leave user content alone */
@scope (.editor-chrome) to (.user-content) {
  button {
    font: inherit;
    padding: 0.4rem 0.8rem;
  }
}

One caveat that catches people: the limit is exclusive of its own subtree but the limit element is itself outside the scope. You cannot style .comments from inside that block — only its ancestors up to the root.

Proximity: the new tiebreaker in the cascade

This is the behaviour most likely to surprise you, because it changes a rule you have relied on for years. When two scoped rules have identical specificity, the winner is the one whose scoping root is closer in the DOM to the matched element — not the one that comes later in the stylesheet.

@scope (.theme-light) {
  p { color: #111; }
}

@scope (.theme-dark) {
  p { color: #eee; }
}

With a .theme-dark nested inside a .theme-light, paragraphs inside the dark region come out light-on-dark, even though .theme-light could equally have matched and the source order does not favour it. Proximity is checked after specificity and before source order.

In practice this is usually what you want — the nearest enclosing context wins, which is how component authors think anyway. But it is a real addition to the cascade, and if you debug CSS by counting specificity and scanning source order, add a third question to your list.

Using it in React

The interesting property for React is that @scope works with a plain style element inside your component, which means the scoping root can be the component instance rather than a class you have to invent.

function Card({ title, children }) {
  return (
    <div className="card">
      <style>{cardCss}</style>
      <h2>{title}</h2>
      <div className="card-body">{children}</div>
    </div>
  );
}

There is a neater trick: an implicit scope. Omit the root selector entirely and the scope root becomes the parent of the style element itself.

@scope {
  :scope {
    display: grid;
    gap: 0.5rem;
  }

  h2 {
    font-size: 1.25rem;
  }
}

Dropped into a component, that stylesheet scopes itself to wherever it lands — no class name, no hash, no build step. It is the closest CSS has come to the ergonomics of scoped styles in a single-file component.

Before you reach for it everywhere, though, note the cost: a style element per rendered instance is a lot of duplicated CSS if you render two hundred cards, and React will not deduplicate it for you. Implicit scope is excellent for one-off layout regions and page shells. It is the wrong tool for a list item.

Should it replace CSS Modules?

Mostly no, and it is worth being clear about why rather than joining the it-replaces-your-build-tool enthusiasm.

  • CSS Modules guarantee isolation at build time. @scope isolates by DOM position at runtime. If a child component happens to render an img inside your .card, your scoped img rule styles it — the scope contains descendants, not just your own markup. That is leakage of a different shape, not the absence of leakage.
  • CSS Modules give you dead-code elimination and a compile error when you reference a class that does not exist. @scope gives you neither.
  • Specificity behaviour differs in a way that matters: the scoping root does not add specificity to the inner selectors, so a bare img inside a scope still has specificity 0-0-1 and loses to any single class elsewhere. People expect scoping to strengthen a rule. It does not.

Where @scope genuinely wins is the set of problems CSS Modules never addressed: styling markup you do not control, carving a hole in your own styles for injected content, and theming by proximity. Those are real and previously painful.

So the useful framing is additive rather than replacing. Keep CSS Modules or Tailwind for component styling in a build-tooled React app. Reach for @scope when you need a donut, when you are styling CMS or third-party output, or when you want a stylesheet that travels with a fragment of markup and cannot rely on a build step at all.

The support caveat that still applies

Baseline newly-available means current stable versions of every major browser support it. It does not mean every browser your users have. A meaningful share of real traffic runs on versions that predate March 2026, and there is no polyfill worth using because the cascade behaviour cannot be faithfully reproduced.

The good news is that @scope degrades cleanly in one direction and badly in another. If the scoped rules are enhancements — spacing, colour, radius — an unsupporting browser drops the whole block and gets unstyled-but-functional output. If you rely on a scoping limit to prevent styles applying somewhere, an unsupporting browser drops the block entirely rather than applying it too widely, which is the safe failure. But if you rely on proximity to resolve a theme conflict, older browsers fall back to source order and you get the wrong theme.

Feature-detect with @supports at-rule(@scope) where the difference is load-bearing, and treat proximity-dependent theming as the one pattern to hold back on for another year.

Worth learning now

@scope is not the feature that deletes your build step, whatever the headlines said in March. It is something more specific and more durable: the first native answer to the question of where a style stops applying, including the case where the thing you want to exclude is nested inside the thing you want to style.

That case used to require either a naming convention you enforced by hand or a selector you were slightly afraid of. Now it is two selectors and an at-rule. Like the Popover API and CSS anchor positioning, it is a piece of the platform quietly absorbing a job we had been doing in JavaScript and tooling — and the sooner you know its shape, the sooner you stop reaching for the old workaround out of habit.