Solved By CSS: Donuts Scopes
Imagine you have a web component that can show lots of different content. It will likely have a slot
somewhere where other components can be injected. The parent component also has its own styles unrelated to the styles of the content components it may hold.
This makes a challenging situation: how can we prevent the parent component styles from leaking inwards?
This isn’t a new problem — Nicole Sullivan described it way back in 2011! The main problem is writing CSS so that it doesn’t affect the content, and she accurately coined it as donut scoping.
“We need a way of saying, not only where scope starts, but where it ends. Thus, the scope donut”.
Even if donut scoping is an ancient issue in web years, if you do a quick search on “CSS Donut Scope” in your search engine of choice, you may notice two things:
- Most of them talk about the still recent
@scope
at-rule. - Almost every result is from 2021 onwards.
We get similar results even with a clever “CSS Donut Scope –@scope
” query, and going year by year doesn’t seem to bring anything new to the donut scope table. It seems like donut scopes stayed at the back of our minds as just another headache of the ol’ CSS global scope until @scope
.
And (spoiler!), while the @scope
at-rule brings an easier path for donut scoping, I feel there must have been more attempted solutions over the years. We will venture through each of them, making a final stop at today’s solution, @scope
. It’s a nice exercise in CSS history!
Take, for example, the following game screen. We have a .parent
element with a tab set and a .content
slot, in which an .inventory
component is injected. If we change the .parent
color, then so does the color inside .content
.
How can we stop this from happening? I want to prevent the text inside of .content
from inheriting the .parent
‘s color.
Just ignore it!
The first solution is no solution at all! This may be the most-used approach since most developers can live their lives without the joys of donut scoping (crazy, right?). Let’s be more tangible here, it isn’t just blatantly ignoring it, but rather accepting CSS’s global scope and writing styles with that in mind. Back to our first example, we assume we can’t stop the parent’s styles from leaking inwards to the content component, so we write our parent’s styles with less specificity, so they can be overridden by the content styles.
body {
color: blue;
}
.parent {
color: orange; /* Initial background */
}
.content {
color: blue; /* Overrides parent's background */
}
While this approach is sufficient for now, managing styles just by their specificity as a project grows larger becomes tedious, at best, and chaotic at worst. Components may behave differently depending on where they are slotted and changing our CSS or HTML can break other styles in unexpected ways.
Two CSS properties walk into a bar. A barstool in a completely different bar falls over.
Thomas Fuchs
You can see how in this small example we have to override the styles twice:
Shallow donuts scopes with :not()
Our goal then it’s to only scope the .parent
, leaving out whatever may be inserted into the .content
slot. So, not the .content
but the rest of .parent
… not the .content
… :not()
! We can use the :not()
selector to scope only the direct descendants of .parent
that aren’t .content
.
body {
color: blue;
}
.parent > :not(.content) {
color: orange;
}
This way the .content
styles won’t be bothered by the styles defined in their .parent
:
You can see an immense difference when we open the DevTools for each example:
As good as an improvement, the last example has a shallow reach. So, if there were another slot nested deeper in, we wouldn’t be able to reach it unless we know beforehand where it is going to be slotted.
This is because we are using the direct descendant selector (>
), but I couldn’t find a way to make it work without it. Even using a combination of complex selectors inside :not()
doesn’t seem to lead anywhere useful. For example, back in 2021, Dr. Lea Verou mentioned donut scoping with :not()
using the following selector cocktail:
.container:not(.content *) {
/* Donut Scoped styles (?) */
}
However, this snippet appears to match the .container
/.parent
class instead of its descendants, and it’s noted that it still would be shallow donut scoping:
TIL that all modern browsers now support complex selectors in :not()! 😍
Test: https://t.co/rHSJARDvSW
So you can do things like:
– .foo :not(.foo .foo *) to match things inside one .foo wrapper but not two
– .container :not(.content *) to get simple (shallow) “donut scope”— Dr Lea Verou (@LeaVerou) January 28, 2021
Donut scoping with @scope
So our last step for donut scoping completion is being able to go beyond one DOM layer. Luckily, last year we were gifted the @scope
at-rule (you can read more about it in its Almanac entry). In a nutshell, it lets us select a subtree in the DOM where our styles will be scoped, so no more global scope!
@scope (.parent) {
/* Styles written here will only affect .parent */
}
What’s better, we can leave slots inside the subtree we selected (usually called the scope root). In this case, we would want to style the .parent
element without scoping .content
:
@scope (.parent) to (.content) {
/* Styles written here will only affect .parent but skip .content*/
}
And what’s better, it detects every .content
element inside .parent
, no matter how nested it may be. So we don’t need to worry about where we are writing our slots. In the last example, we could instead write the following style to change the text color of the element in .parent
without touching .content
:
body {
color: blue;
}
@scope (.parent) to (.content) {
h2,
p,
span,
a {
color: orange;
}
}
While it may seem inconvenient to list all the elements we are going to change, we can’t use something like the universal selector (*
) since it would mess up the scoping of nested slots. In this example, it would leave the nested .content
out of scope, but not its container. Since the color
property inherits, the nested .content
would change colors regardless!
And voilà! Both .content
slots are inside our scoped donut holes:
Shallow scoping is still possible with this method, we would just have to rewrite our slot selector so that only direct .content
descendants of .parent are left out of the scope. However, we have to use the :scope
selector, which refers back to the scoping root, or .parent
in this case:
@scope (.parent) to (:scope > .content) {
* {
color: orange;
}
}
We can use the universal selector in this instance since it’s shallow scoping.
Conclusion
Donut scoping, a wannabe feature coined back in 2011 has finally been brought to life in the year 2024. It’s still baffling how it appeared to sit in the back of our minds until recently, as just another consequence of CSS Global Scope, while it had so many quirks by itself. It would be unfair, however, to say that it went under everyone’s radars since the CSSWG (the people behind writing the spec for new CSS features) clearly had the intention to address it when writing the spec for the @scope
at-rule.
Whatever it may be, I am grateful we can have true donut scoping in our CSS. To some degree, we still have to wait for Firefox to support it. 😉
Desktop
Chrome | Firefox | IE | Edge | Safari |
---|---|---|---|---|
118 | No | No | 118 | 17.4 |
Mobile / Tablet
Android Chrome | Android Firefox | Android | iOS Safari |
---|---|---|---|
131 | No | 131 | 17.4 |