Scoped CSS is Back

Updated 25 April 2023: Added more example code and rephrased a few things for clarity based on feedback.

Several years ago, I made a plea to save scoped CSS. One of the top features on my CSS wishlist was on the chopping block, and despite a pretty big push from the community, it died.

Well, great news — it’s back. And it’s so much better than the previous version.

Even better, the W3C spec is mostly stable, and there’s a working prototype in Chrome now. We just need a little interest from the community to entice other browsers to build their implementations and kick this over the finish line.

What’s the idea

There are two key things scope brings to CSS:

  1. More control over which selectors target which elements (i.e. better manipulation of the cascade)
  2. The ability for one set of styles to override another based on proximity in the DOM

Scoped styles allow you to contain a set of styles within a single component on the page. You can use a .title selector that only works within a Card component, and a separate .title selector that only works in an Accordion. You can stop selectors from one component from targeting elements in a child component — or you can allow them reach in, if that’s what you need.

You will not need BEM-style classnames anymore.

Furthermore, proximity becomes a first-class citizen in the cascade. If two components target the same element (with the same specificity), the inner component’s styles will override those of the outer component.

How it works

It all starts with the @scope rule and a selector, like this:

@scope (.card) {
  /* Scope the following styles to inside `.card` */
  :scope {
    padding: 1rem;
    background-color: white;
  }

  .title {
    font-size: 1.2rem;
    font-family: Georgia, serif;
  }
}

These styles are all scoped to .card elements. :scope is a special pseudo-class that targets the .card element itself, and .title targets titles inside cards.

The @scope rule itself adds no specificity to these selectors, so they’re both (0, 1, 0). Yes, specificity still matters, but that’s a Good Thing™️. More on that shortly.

At this point, this is nothing you can’t already do with regular descendant selectors. But new, previously impossible options start opening up when you apply an inner bound to the scope or overlap multiple scopes on the page. Let’s see what those do…

Inner scope bound

Let’s say you anticipate putting other components inside your Cards, so you don’t want that .title selector to target anything other than the one title that belongs to the Card. To do that, you put an inner-bound on the scope like so:

@scope (.card) to (.slot) {
  /* Scoped styles target only inside `.card` but not inside `.slot` */
  :scope {
    padding: 1rem;
    background-color: white;
  }

  .title {
    font-size: 1.2rem;
    font-family: Georgia, serif;
  }
}

Think of the “to” keyword here like “until”: this scope is defined from .card to .slot. Now, none of the scoped selectors will target anything inside the Card’s .slot element. So you can build your card like this:

<div class="card">
  <h3 class="title">Moon lander</h3>
  <div class="slot">
    <!-- scoped styles won’t target anything here! -->
  </div>
</div>

The reach of the scope is restricted, keeping it from targeting anything inside .slot. This way, you can nest two scopes, and each one can make use of the same generic title class name without conflicting. In fact, you might not even need the class name anymore at all:

@scope (.card) to (.slot) {
  h3 {
    font-size: 1.2rem;
    font-family: Georgia, serif;
  }
}

@scope (.accordion) to (.slot) {
  h3 {
    font-family: Helvetica, sans-serif;
    text-transform: uppercase;
    letter-spacing: 0.01em;
  }
}

You can put an Accordion inside a Card — or a Card inside an Accordion — and they will each style their own <h3>s without conflicting.

This has been colloquially named “donut scoping” since the scope has a hole in it. (It can also have multiple holes in it, if the inner bound selector targets multiple elements.) Miriam Suzanne suggest a possible way to use this is to consistently use data-* attributes and attribute selectors for your scope:

@scope ([data-scope='media']) to (:scope [data-scope]) {
  /* scoped styles go here */
}

…though I find I prefer the simple selectors that come from a class-based approach. But maybe that’s just my old BEM habits showing.

Proximity precedence

The other aspect to scoping is the concept of proximity: styles from an inner scope will override those from an outer scope. Imagine you have two scopes like this:

@scope (.green) {
  p {
    color: green;
  }
}

@scope (.blue) {
  p {
    color: blue;
  }
}

Apply these to the following HTML. These have no inner scope bound, so both p selectors target the inner paragraphs here. In this case, the inner scope always takes precedence:

<div class="green">
  <p>I’m green</p>
  <div class="blue">
    <p>I’m blue</p>
  </div>
</div>

<div class="blue">
  <p>I’m blue</p>
  <div class="green">
    <p>But I’m green</p>
  </div>
</div>

And here’s a working demo — Note this currently only works in Chrome with the Experimental Web Platform Features flag turned on in chrome://flags. 1

I’m green

I’m blue

I’m blue

But I’m green

You can inspect this in DevTools and see each scope overriding the other, based on which one has the closest proximity:

DevTools showing the green scope winning over the blue

and

DevTools showing the blue scope winning over the green

The catch here is that selector specificity still takes precedence, so if the outer scope targets an element with higher specificity than the inner one, the outer scope’s styles will apply. This was a debated issue during the development of the specification, but — to the surprise of my past self — I think it’s the right call.

This way, when two scopes target the same element, you maintain control over which takes precedence. Instead of the inner scope always winning, you can tweak selector specificities so the higher-specificity selector takes precedence, regardless which scope it belongs to.

And when you don’t want this behavior, you have a few ways to prevent it. You make use of cascade layers to give one component — or just parts of one component — precedence over another. Or, you can apply an inner scope bound to the outer scope to keep it from happening. After experimenting with scope for a bit, this feels to me like the right balance. It gives you the most control, rather than leaving you beholden to a rigid set of rules of the cascade.

This is a game changer

If you’ve developed large-scale apps and had to rely on CSS-in-JS libraries to prevent class name collisions, I hope you can see the benefit this offers. If you’ve rolled out complex BEM class name systems and fought to keep all your selector specificities equal, think of the freedom this can bring. If you’ve ever used the shadow DOM to isolate styles but it was too heavy-handed, this is a better way (though there are still use-cases for Shadow DOM, of course).

I can’t even imagine all the new ways we’ll be able to structure our code. I think this will be as big as custom properties or even flexbox and grid. Here are just a few ideas that I’ll certainly be experimenting with:

  • Define parts of a component with an inner bound and parts of it without, so the scope of its ”chrome” styles (i.e. wrapper, toggle buttons, etc) don’t affect its child contents, but it can influence the appearance of text within.
  • Define portions of a component on different cascade layers so it can influence its contained scopes but remain simple to override on a higher layer.
  • Nested color themes.
  • Easier ways to prevent style collisions in embedded demos in my blog posts.
  • Container queries—What can we come up with by mixing and matching with those?

We need more browsers on board

At this point, Chrome seems to be on board—they’ve had the first working prototype for several months now. It might be slightly behind the latest changes to the spec, so keep an eye out for a few minor changes coming if you play around with it.

Firefox seems more reluctant—possibly because they got burned as the only implementors of the first version of scope several years ago? I’m not sure what the best way is to nudge them along. Perhaps we just need more people blogging about this feature and creating buzz. There is this open issue requesting their position on the feature. Maybe a little activity there would help.

It sounds like Safari has expressed interest, but it never hurts to let them know if the community would like to see it. Maybe the next time Jen Simmons takes a poll on most requested features, let her know you want to see @scope happen.

Until then, have fun experimenting in Chrome!

Loading interactions…

Recent Posts

See all posts