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.
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:
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:
Bringing it all together
#I don’t want to make it sound like scope and nesting are in competition here:
- Use
@scope
to define specific patterns or components and their block-element relationships. Those can be broad (entire themes) or narrow (single buttons) and might overlap sometimes. It’s fine for elements to belong in multiple scopes, but each scope is specific to some fragment of the DOM. - Nesting exists to make complex selectors more readable. It can handle a wider variety of selectors than
@scope
because it is not designed for one specific use-case. While@scope
is excellent at expressing theblock-element
relationship, nesting is still the clear choice for theelement--modifier
relationships, like pseudo-classes.
Some other recent features overlap with scope in interesting ways:
- Use
@layer
to group and prioritize different styling concerns. They tend to be more broad and architectural – resets, frameworks, design systems, and utilities – and apply to any number of components. A button component and a theme component might both start with ‘default’ layers. - Scope roots will often be good container elements for
@container
queries. I expect it will be common to see:scope { container: my-scope-name / inline-size; }
at the top of many@scope
rules.
Happy scoping!
Miriam selected The Trevor Project for an honorary donation of $50
The Trevor Project’s mission is to end suicide among LGBTQ young people.