Since 2021 the proposal for a native CSS nesting solution has been in draft, and earlier this year it found its way into our browsers. This makes it close to becoming a set in stone web-standard as opposed to a proposed standard.
Here is some valid CSS using the new nesting proposal.
.custom-table {
& td {
text-align: center;
&.highlight {
background-color: var(--color-secondary);
}
}
& th {
text-align: center;
font-weight: bold;
}
}
We've been able to enjoy nesting for many years now, through pre-processors like SASS which do a basic string concatenation of our selectors, joining them together into a browser-compatible representation in plain-old CSS. But there's a fundamental difference between SASS and the CSS that will break a core methodology in the CSS ecosystem, and that's BEM.
Some Background
Skip this section if you already know the pitfalls of messy CSS and how BEM assists with that. If you want to see an alternative approach to BEM with the same advantages that works with native CSS nesting, here's a [link forwards].
BEM started at Yandex some 17 years ago to alleviate the pain and chaos by their existing CSS structure. Prior they would be make up of an id-selector for the block and a cascade of classes to style elements within that block.
#card-list {
padding: 0.5rem 1rem;
border: 1px solid var(--black);
}
#card-list .card {
margin-bottom: 0.5rem;
}
#card-list .card .header {
box-shadow: 0 0 0 8px rgba(0, 0, 0, 0.2);
}
#card-list .card .header .image {
width: 100%;
margin-bottom: 0.25rem;
}
#card-list .card. header .heading {
font-size: 2rem;
}
#card-list .card .content {
padding: 0 0.5rem;
}
With a structure like this you have to be very careful, because you don't necessarily know how the classes you add to your cascade may affect styles in any nested blocks, or any styles that come from where your block is nested within.
<div id="card-list">
<div class="card">
<figure class="header">
<img src="..." alt="..." class="image" />
<figcaption class="heading">An Image</figcaption>
<!-- Insert nested block -->
</figure>
<div class="content">
<!-- Insert nested block -->
</div>
</div>
</div>
And let's say your nested block also happens to make use of a .heading
class.
<div id="card-list">
<div class="card">
<figure class="header">
<img src="..." alt="..." class="image" />
<!-- hits #card-list .card .header .heading -->
<figcaption class="heading">An Image</figcaption>
<div id="price-comparison">
<!-- hits #card-list .card .header .heading -->
<!-- hits #price-comparison .heading -->
<h2 class="heading">Products</h2>
<!-- ... -->
</div>
</figure>
<!-- ... -->
</div>
</div>
Now your nested .heading
in #price-comparison
has a font-size of 2rem
from the .heading
in #card-list
instead of whatever font-size is intended.
Well, the simplest way to get over that hump now is to ensure the specificity of .heading
in #card-list
has a high enough specificity.
/* Specificity (1, 3, 0) */
#card-list .card .header .heading {
font-size: 2rem;
}
/* Specificity (1, 1, 0) */
#price-comparison .heading {
font-size: 1.5rem
}
/* A specificity of (1, 3, 0) takes precedence over (1, 1, 0) */
A common way to get around this is to use !important
which ensures that a given property wins over any other non-important properties
/* Specificity (!, 1, 1, 0) */
#price-comparison .heading {
font-size: 1.5rem ;
}
/* A specificity of (!, 1, 1, 0) takes precedence over (1, 3, 0) */
But the issue is that if we have further nesting issues and have to overwrite that !important
, then the usage of !important
tends to run rampant and become difficult to maintain.
So, an alternate approach is to at least match the specificity of what you're trying to beat, and then ensuring your block is in the correct order of the cascade.
/* Specificity (1, 3, 0) */
#price-comparison .heading.heading.heading {
font-size: 1.5rem;
}
/* A specificity (1, 3, 0) only takes precence over (1, 3, 0) by order */
But then we may end up with the same issue as !important
, and tend to use these ugly repeated selectors reach a matching specificity.
So the next logical step is - maybe we can reduce the specificity of our original selectors. Do we really need to include multiple classes for our cascade of selectors.
Let's take another look at our original stylesheet for our #card-list
and cut down what’s in between the block and target element for each selector.
#card-list {
padding: 0.5rem 1rem;
border: 1px solid var(--black);
}
#card-list .card {
margin-bottom: 0.5rem;
}
#card-list .header {
box-shadow: 0 0 0 8px rgba(0, 0, 0, 0.2);
}
#card-list .image {
width: 100%;
margin-bottom: 0.25rem;
}
#card-list .heading {
font-size: 2rem;
}
#card-list .content {
padding: 0 0.5rem;
}
And then realising that having IDs in our selectors cranks up the impact of our specificity, we may choose to use classes for the roots of our component instead.
.card-list {
padding: 0.5rem 1rem;
border: 1px solid var(--black);
}
.card-list .card {
margin-bottom: 0.5rem;
}
.card-list .header {
box-shadow: 0 0 0 8px rgba(0, 0, 0, 0.2);
}
.card-list .image {
width: 100%;
margin-bottom: 0.25rem;
}
.card-list .heading {
font-size: 2rem;
}
.card-list .content {
padding: 0 0.5rem;
}
But now, when writing HTML, it can become card to identify which element is the root of our component, which we were originally identifying by the id
attribute.
Also, because we have widened the scope of our selectors, it has become more likely that our .header
class for example is going to accidentally affect the styles of nested components with their own .header
class. So, we need some sort of way to couple our elements styles with the component and only the component it belongs to.
The way BEM solved this coupling issue was to join classes together, connecting elements to blocks via __
and modifiers to elements/blocks via —-
.card-list {
padding: 0.5rem 1rem;
border: 1px solid var(--black);
}
.card-list__card {
margin-bottom: 0.5rem;
}
.card-list__header {
box-shadow: 0 0 0 8px rgba(0, 0, 0, 0.2);
}
.card-list__image {
width: 100%;
margin-bottom: 0.25rem;
}
.card-list__heading {
font-size: 2rem;
}
.card-list__content {
padding: 0 0.5rem;
}
Now, specificity is rarely an issue as we are always dealing with selectors that have a single class, and now our nested elements have no risk of class collisions as they are intrinsically tied to the block.
Now this background we’ve gone through is mostly conjecture, so if you’d like a more true-to-history background I recommend you take a look at this article on BEM’s history.
We do have to go a little further though, because eventually we had this tool come around called SASS who’s main feature is to nest our CSS. This nesting has become popular with BEM as it helps us to avoid repeating ourselves on writing block and elements names and it also gives us a visual structure to our CSS making it easier to navigate.
.card-list {
padding: 0.5rem 1rem;
border: 1px solid var(--black);
&__card {
margin-bottom: 0.5rem;
}
&__header {
box-shadow: 0 0 0 8px rgba(0, 0, 0, 0.2);
}
&__image {
width: 100%;
margin-bottom: 0.25rem;
}
&__heading {
font-size: 2rem;
}
&__content {
padding: 0 0.5rem;
}
}
The output of the CSS is the same as in the previous example, but now we more clearly see which styles are a child of another. And, if we wish to change our root class from .card-list
to something else, we only need to change it in one place.
So now we know a little better how we got here. Let's take a look at what's new with native CSS nesting.
The difference between SCSS and Native CSS
For SCSS, the process of transforming nesting from SCSS to CSS is a matter of string concatenation. Consider the following...
.foo {
.bar {
&--baz {
color: red;
}
}
}
The selectors are stitched together, with a space being added when the &
symbol is omitted.
.foo .bar--baz { color: red; }
In this way, the conceptual model for SCSS is very simple to understand and follow. The only real gotcha is that SCSS will always boil down to CSS and you may not be able to see with complete clarity the size of the CSS being output.
Consider the following, where each nested selector list increases the space complexity from SCSS to CSS. In this case O(n^3)
.one,
.two,
.three {
.alpha,
.beta,
.charlie {
.foo,
.bar,
.baz {
color: red;
}
}
}
/* Result */
.one .alpha .foo,
.one .alpha .bar,
.one .alpha .baz,
.one .beta .foo,
.one .beta .bar,
.one .beta .baz,
.one .charlie .foo,
.one .charlie .bar,
.one .charlie .baz,
.two .alpha .foo,
.two .alpha .bar,
.two .alpha .baz,
.two .beta .foo,
.two .beta .bar,
.two .beta .baz,
.two .charlie .foo,
.two .charlie .bar,
.two .charlie .baz,
.three .alpha .foo,
.three .alpha .bar,
.three .alpha .baz,
.three .beta .foo,
.three .beta .bar,
.three .beta .baz,
.three .charlie .foo,
.three .charlie .bar,
.three .charlie .baz {
color: red;
}
Native CSS
In native CSS, selectors are computed differently based on the type of selector used, rather than relying on string concatenation as in SCSS. So just like with CSS variables when in comparison to SCSS variables, CSS nesting is computed during runtime which makes our selectors more performant and in some cases more dynamic.
So, because the selectors are constructed, rather than being concatenated; depending on the type of nested selector - the part of the selector may be computed different to expected. Consider the case of a nested tag.
.foo {
&input {
margin: 1rem;
}
}
/* Result in SCSS */
.fooinput {
margin: 1rem;
}
/* Equivalent in CSS */
input.foo {
margin: 1rem;
}
Native CSS identifies the input
here to be a tag name, and so places it in the correct part of the selector.
So what happens when we try to migrate our previously SASS based BEM files to our new fancy schmancy native CSS by simply changing the file extension?
.card-list {
padding: 0.5rem 1rem;
border: 1px solid var(--black);
&__card {
margin-bottom: 0.5rem;
}
/* ... */
}
/* Non-nested equivalent */
.card-list {
padding: 0.5rem 1rem;
border: 1px solid var(--black);
}
__card.card-list {
margin-bottom: 0.5rem;
}
Well, we will find that our BEM selectors no longer work. Because the nested &__card
doesn't start with a class (.
) or ID (#
) - or any other symbol that informs us of the type of selector (eg @at-rules
, :pseudo
, etc) - will be considered a tag. And __card
is neither a valid HTML tag nor what we want anyway.
Rethinking BEM
We explored some of the reasons we may choose to use BEM in our background. Plain and simple, with BEM;
- We could write nested styles without increasing specificity: Both
block
andblock__element
have a specificity of(0, 1, 0)
- Prevent name collisions: It is unlikely you will have two separate components using
.block__element
by accident. For example, if we used.card-list .title
, it is likely that we could affect a deeply nested.title
that belongs to another component, eg.price-comparison .title
- Readability: It is easy to understand that
.block__element
belongs to.block
- Prevent rewriting parts of selector: We can more easily write and maintain
&__element
than.block__element
So how do we achieve these benefits in a world where __element
and --modifier
are no longer a valid way of doing things.
:where(BEM)
I've been having some discussions at work to explore how we may continue to take advantage of BEM in a SASS-less future.
My jab at a solution aims to achieve these benefits of BEM by utilising the :where
pseudo-class function.
We can use :where
to solve that first issue
- How can we nest selectors without increasing specificity: We can use the
:where
function to nest part of a selector without increasing specificity
.block {
:where(.element) {}
&:where(.modifier) {}
}
- Prevent name collisions: For modifiers, we are avoiding collisions by the mere fact that we're acting on the same element. For elements, our best bet is to rely on relative selectors, so we're sure we're acting on an element we know is in the place we expect.
.block {
>:where(.element) {}
&:where(.modifier) {}
}
Using relative selectors though may not be ideal in every case though, if we can't have our CSS so highly coupled with the structure of our HTML. We may want to have varied levels of nesting.
So, in this case, I would suggest using some sort of unique hash or salt to ensure uniqueness. You could use css-modules if you want this done for you.
If you want to keep things readable and obvious that a particular element belongs to a particular block, I like to name my salt something like .of-block
.block {
:where(.element.of-block) {}
>:where(.element) {}
&:where(.modifier) {}
}
- Readability: It's hard to match how explicit BEM is, however, we can rely on the aforementioned "hash"/"salt" when needed.
- Prevent Rewriting parts of a selector: By continuing to use nesting, we have kept ourselves from needing to rewrite parts of the selector.
So, if we chose to rewrite our earlier examples with the :where(BEM)
, it would look something like this
.card-list {
padding: 0.5rem 1rem;
border: 1px solid var(--black);
> :where(.card) {
margin-bottom: 0.5rem;
}
:where(.header.of-card-list) {
box-shadow: 0 0 0 8px rgba(0, 0, 0, 0.2);
> :where(.image) {
width: 100%;
margin-bottom: 0.25rem;
}
> :where(.heading) {
font-size: 2rem;
}
}
:where(.content.of-card-list) {
padding: 0 0.5rem;
}
}
<div id="card-list">
<div class="card">
<figure class="header of-card-list">
<img src="..." alt="..." class="image" />
<figcaption class="heading">An Image</figcaption>
<!-- Insert nested block -->
</figure>
<div class="content of-card-list">
<!-- Insert nested block -->
</div>
</div>
</div>
From the example, you could consider the following pros and cons
Pro | Con |
---|---|
Less repetition in HTML class names | Harder to tell the block in which an element belongs to. This could be helped with the use of a "salt" if needed |
Deeper nesting of CSS. Again, this could be aided with the use of a "salt" | |
Repeated use of :where is tedious |
Is it worth it
You may be wondering whether it's even worth changing the ways we do things just to be able say that we're using this new thing. In my opinion it is, and for several reasons
- Smaller footprint: Because we don't need to compile from SCSS to a strung out version of CSS we are able to save users a fair bit of bandwidth. Yes Gzip is very good at compressing repeated text, but it will never beat not having that text there in the first place.
- Instant Build times: On larger projects I've worked on it can take up to a minute for a developer server to recompile my SASS and can cost precious resources in a CI pipeline. Native CSS is build to be fast; it doesn't need to be compiled and it's made to be parsed fast enough for a browser to interpret on the fly.
- It will be a web-standard: There's no need to worry about deprecation warnings or losing support. Once it's in the spec, you can expect very long-term support.