Dec 15

CSS @scope

The new `@scope` rule is here! It's a better way to keep our component styles contained – without relying on third-party tools or extreme naming conventions.

By Miriam Suzanne

Scoped styles have always been the primary goal of CSS. Selectors scope declarations to matched elements. Those selectors can be combined to create more specific scopes – each modified by its relation to others. But the @scope feature, already available in Chromium browsers, will make that functionality even more powerful.

Historically, relational selectors were limited in what scopes they could express, and the results can feel fragile if we’re not careful. Narrow relations like the > ‘child’ combinator break if we add a wrapping element around the child. Broader relations like the ‘descendant’ combinator can easily bleed into nested content. That’s fine when we really mean ‘direct children’ or ‘all descendants’ – but often, we’re trying to express a more abstract idea of belonging.

Some styles are global, but many styles belong to a component. When we select the article .title we don’t necessarily want all the .title elements inside the article, but specifically the .title that belongs to the article. This is what people mean when they ask for scope in CSS. Selectors that convey not just nesting, but belonging.

Nicole Sullivan pointed to these issues back in 2009, and since then authors have developed a number of elaborate workarounds. The BEM (e.g. block-element--modifier) naming convention has been dominant for over a decade, with more and more third-party tools providing ‘scoped styles’ as a build step.

Fifteen years of scope hacks

To express belonging in CSS, we need two parts – what BEM would call the ‘block’ and the ‘element.’ Elements belong to blocks. To make that relation clearer than simply nesting, BEM requires adding the block name to every element and selector in the scope:

/* nesting: all matching elements in the block */
.block .element {}

/* BEM: just the elements belonging to the block */
.block-element {}

Most front-end frameworks now provide scoped styles, generating unique block names and appending them to elements automatically:

/* Frameworks (the details vary) */
.element[data-scope=block] {}

But naming conventions require strict adherence across an organization, and automation requires full control of both the HTML and CSS output in a single build step. It’s an invasive process that applies unique attributes throughout the DOM and appends them to every relevant selector. 

These are not reliable solutions to the problem; they’re hacks we’ve grown used to over the last fifteen years.

(Re-) Introducing CSS @scope

The new CSS @scope rule allows us to define a scoped relationship in two parts. First, we define the block itself by selecting the ‘root’ of a given scope. Those selectors go in the ‘prelude’ to the rule:

@scope (.block) {}

Then, we can define ‘scoped’ selectors that belong in the scope of that block:

@scope (.block) {
.element {}
}

This might be familiar if you’ve used CSS nesting. It will select the same elements as either of the following:

.block {
.element {}
}

.block .element {}

With nesting, there’s an implicit & before each nested selector, but you can also place the & explicitly if you want. The & selector is a stand-in for the nesting parent:

.block {
.element { /* implied starting & */ }
& .element { /* explicit starting & */ }
.context & { /* repositioned & */ }
}

Scope works the same way, but using the :scope selector by default:

@scope (.block) {
.element { /* implied starting :scope */ }
:scope .element { /* explicit starting :scope */ }
.context :scope { /* repositioned :scope */ }
}

They look similar, but don’t let that fool you. CSS nesting is a shorthand for writing multi-part (complex) selectors. But despite being written in multiple steps, the resulting selectors only match a single element: the final ‘subject’ of the combined selector. Nested relationships can be referenced along the way to narrow down our subjects, but those relationships don’t ‘stick around’ after selection is complete. 

To make the scope more meaningful in the cascade, we need to know both the element that we’re styling and also the blocks that it belongs to. The scope rule achieves that by explicitly breaking selection into two distinct parts with two distinct targets: a scope-root selector and a subject-element selector. That subtle change makes all the difference.

Scope Proximity

Because we know the relationship between the subject and the scope it belongs to, we can do things like measure their proximity – the number of DOM steps between them:

<article class=’block’>
<p class=’element’>
Only 1 step
from .element to .block.
</p>
<footer>
<div>
<p class=’element’>
3 steps from .element
(through div and footer)
to .block.
</p>
</div>
</footer>
</article>

Scope proximity has been added to the cascade after selector specificity but before order of appearance. When two selectors with equal specificity apply to the same element, the selector with a ‘closer’ scope-root will win.

See the CodePen.

This is especially helpful in situations where the two selectors serve the same purpose, and we might want broadly overlapping scopes – like setting link colors from different themes. But other ways exist to achieve a similar result, like custom properties (which inherit based on proximity). Things get more exciting when we add a third selector to the syntax.

Scope Boundaries

The real power of a component-based approach is the ability to compose new patterns by combining blocks in various ways. The ‘tabs’ component is only useful when we put other content inside each tab panel. However, the tab element doesn’t know what that content will be, so the scope of our ‘tab’ component styles should stop where the content begins. Nicole calls that ‘donut scope’ – when components have a hole in the middle (or a slot) for other content to go inside.

That causes a problem for nested selectors, which apply to all descendants equally. So we rely instead on third-party tools and BEM syntax to generate unique identifiers and apply them to each element in the donut. The @scope rule makes that possible without any conventions or tools. We can provide a second selector in the prelude:

@scope (.block) to (.slot) {
.element {}
}

You can see this work in any up-to-date Chromium browser:

See the CodePen.

Scoped Rules Keep Specificity Low

Another big difference between nesting and @scope is the specificity of the selectors. Nested selectors are combined into one, with a combined specificity to match:

#banner {
/* specificity: [1, 0, 1] */
h1 { color: hotpink; }
}

But with scope, we use two or three distinct selectors. They are never joined together, and so they don’t result in a combined specificity:

@scope (#banner) {
/* specificity: [0, 0, 1] */
h1 { color: hotpink; }
}

Many authors like that BEM syntax ‘flattens’ specificity by using a single class for everything. But selection is half of what CSS does! If we can only use classes, we’re giving up some of the most powerful and expressive features in the language. By using scope instead of nesting when it makes sense, we can get the best of both worlds!

Using an explicit :scope selector will add some specificity – a pseudo-class has the same specificity as a normal class. It refers to the scope root element but without directly referencing the scope-root selector. If we do want to combine the two selectors and get their combined specificity, we can use the & instead:

@scope (#banner) {
/* specificity: [0, 1, 1] */
:scope h1 { color: hotpink; }

/* specificity: [1, 0, 1] */
& h1 { color: hotpink; }
}

Implicit Scopes and Embedded Styles

Sometimes, it can be useful to embed styles directly in the page along with the component that they style. We can use scopes there, too! An @scope rule without a defined root selector will use the parent element as an implicit scope root:

See the CodePen.

Bringing it all together

I don’t want to make it sound like scope and nesting are in competition here:

Some other recent features overlap with scope in interesting ways:

Happy scoping!

Miriam Suzanne

Miriam Suzanne

Miriam is an artist, engineer, and open-web advocate. She’s a co-founder of OddBird, Invited Expert on the W3C CSS Working Group, and Sass core contributor who enjoys pushing the boundaries of web technology. These days she’s working on specifications for Container Queries, Scope, and Cascade Layers in CSS; extending the Sass color module to support wide-gamut colors; and learning to crochet socks.

Miriam selected The Trevor Project for an honorary donation of $50

The Trevor Project

The Trevor Project’s mission is to end suicide among LGBTQ young people.