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!

Replies

  • @keithjgrant I'm a little on the fence here. What are the advantages of this approach over the kind of scoping JS libraries provide? Is it the fact that the same CSS-Code applies regardless whether it's rolled in a JS component or not?
    To me the most compelling aspect is the proximity precedence concept – I wish this would apply everywhere in CSS.

    JoachimTillessen24 Apr 2023
  • @jtillessen Yeah exactly — with native CSS @scope, you don't need a JS lib to manage it, so better performance and a simpler build pipeline. Plus the ability to reuse scopes across both JS-driven apps and statically-built content.

    Keith J Grant24 Apr 2023
  • @jtillessen …it also brings scoping to sites that aren't full SPAs and don't need to be

    Keith J Grant24 Apr 2023
  • @keithjgrant you're right this is a pretty big deal. Although it works in the right setup, it should never have been JS's job to begin with.

    JoachimTillessen24 Apr 2023
  • @keithjgrant is BEM out of style now?
    I didnt use it to begin with.

    nosh :fosstodon: (ʘ‿ʘ)24 Apr 2023
  • @keithjgrant someone needs to tag @jensimmons so she can take notice, lol 😂 ☝🏼

    nosh :fosstodon: (ʘ‿ʘ)24 Apr 2023
  • @keithjgrant This is an amazing post! If I may make one suggestion: tile and title look way too similar and I initially confused the two, until I realized the difference. Maybe consider renaming one of them: Instead of tile, maybe call it card?

    Thomas Steiner :chrome:25 Apr 2023
  • @keithjgrant Is this just syntactic sugar over writing `.tile .title { … }`? If I still need to give a unique wrapper selector, what is the advantage of using this over new Nested CSS selectors?

    Murat Çorlu25 Apr 2023
  • @scope isn’t a replacement for Shadow DOM, it’s a different tool. Grid and Flexbox have some overlap, but neither is "better" than the other. They work really well together.

    I believe @scope and Shadow DOM will too

    Keith J Grant25 Apr 2023
  • @keithjgrant I'm a bit confused that `@scope (.card) to ( .slot)" does not scope the card styling to the slot element but... the opposite. Is that really correct?

    Large Heydon Collider25 Apr 2023
  • @keithjgrant ohhhhh okay that makes more sense now, thanks. That is rather ambiguous though 😬

    Large Heydon Collider25 Apr 2023
  • @keithjgrant The word until exists, just saying. Maybe I should write specs (probably not).

    Large Heydon Collider25 Apr 2023
  • @heydon @keithjgrant Yeah, that was a fun conversation in the group. I really don't have a strong preference - but there was clearly no english term that reliably meant the same thing to everyone. At some point you're just inventing a language.

    Terribly Federated Mia25 Apr 2023
  • @keithjgrant Wow! This looks so useful! I really hope Firefox & Safari back it and make this a thing! 🤞

    James Nash26 Apr 2023
  • @pepelsbey @mia @annevk if it works as you described, then it seems Canary supports a weird hybrid of both the old and the new approaches 😅

    Keith J Grant2 Jun 2023
  • @mia @keithjgrant @annevk I hope this spec and the browser implementation will finally convince HTML editors to allow <style> not just in the metadata context.

    Vadim Makeev2 Jun 2023
  • @keithjgrant Yea I was kind of also including @scope in my mind. Everything that would add some "context" to style, thing it wasn't possible before.

    Gonna edit my post to make it a bit more clear

    Tixie Salander ????12 Dec 2023
  • @keithjgrant damn, BEM save us from this ????

    Benjamin Egon7 Mar 2024
  • @keithjgrant
    yeah, I also wrote this in the post, I just picked out this one use case, because I was looking just for that just now.

    Wolfram wants peace11 Oct 2024

Recent Posts

See all posts