Published: Oct 8, 2024
To fix some weird quirks with CSS nesting, the CSS Working Group resolved to add the CSSNestedDeclarations
interface to the CSS Nesting Specification. With this addition, declarations that come after style rules no longer shift up, among some other improvements.
These changes are available in Chrome from version 130 and are ready for testing in Firefox Nightly 132 and Safari Technology Preview 204.
The problem with CSS nesting without CSSNestedDeclarations
One of the gotchas with CSS nesting is that, originally, the following snippet does not work as you might initially expect:
.foo {
width: fit-content;
@media screen {
background-color: red;
}
background-color: green;
}
Looking at the code, you would assume that the <div class=foo>
element has a green
background-color
because the background-color: green;
declaration comes last. But this isn’t the case in Chrome before version 130. In those versions, which lack support for CSSNestedDeclarations
, the background-color
of the element is red
.
After parsing the actual rule Chrome prior to 130 uses is as follows:
.foo {
width: fit-content;
background-color: green;
@media screen {
& {
background-color: red;
}
}
}
The CSS after parsing underwent two changes:
- The
background-color: green;
got shifted up to join the other two declarations. - The nested
CSSMediaRule
was rewritten to wrap its declarations in an extraCSSStyleRule
using the&
selector.
Another typical change that you’d see here is the parser discarding properties it does not support.
You can inspect the “CSS after parsing” for yourself by reading back the cssText
from the CSSStyleRule
.
Try it out yourself in this interactive playground:
Why is this CSS rewritten?
To understand why this internal rewrite happened, you need to understand how this CSSStyleRule
gets represented in the CSS Object Model (CSSOM).
In Chrome before 130, the CSS snippet shared earlier serializes to the following:
↳ CSSStyleRule
.type = STYLE_RULE
.selectorText = ".foo"
.resolvedSelectorText = ".foo"
.specificity = "(0,1,0)"
.style (CSSStyleDeclaration, 2) =
- width: fit-content
- background-color: green
.cssRules (CSSRuleList, 1) =
↳ CSSMediaRule
.type = MEDIA_RULE
.cssRules (CSSRuleList, 1) =
↳ CSSStyleRule
.type = STYLE_RULE
.selectorText = "&"
.resolvedSelectorText = ":is(.foo)"
.specificity = "(0,1,0)"
.style (CSSStyleDeclaration, 1) =
- background-color: red
Of all the properties that a CSSStyleRule
has, the following two are relevant in this case:
- The
style
property which is aCSSStyleDeclaration
instance representing the declarations. - The
cssRules
property which is aCSSRuleList
that holds all nestedCSSRule
objects.
Because all declarations from the CSS snippet end up in the style
property of the CSStyleRule
, there is a loss of information. When looking at the style
property it’s not clear that the background-color: green
was declared after the nested CSSMediaRule
.
↳ CSSStyleRule
.type = STYLE_RULE
.selectorText = ".foo"
.style (CSSStyleDeclaration, 2) =
- width: fit-content
- background-color: green
.cssRules (CSSRuleList, 1) =
↳ …
This is problematic, because for a CSS engine to work properly it must be able to distinguish properties that appear at the start of a style rule’s contents from those that appear interspersed with other rules.
As for the declarations inside the CSSMediaRule
suddenly getting wrapped in a CSSStyleRule
: that is because the CSSMediaRule
was not designed to contain declarations.
Because CSSMediaRule
can contain nested rules–accessible through its cssRules
property–the declarations automatically get wrapped in a CSSStyleRule
.
↳ CSSMediaRule
.type = MEDIA_RULE
.cssRules (CSSRuleList, 1) =
↳ CSSStyleRule
.type = STYLE_RULE
.selectorText = "&"
.resolvedSelectorText = ":is(.foo)"
.specificity = "(0,1,0)"
.style (CSSStyleDeclaration, 1) =
- background-color: red
How to solve this?
The CSS Working Group looked into several options to solve this problem.
One of the suggested solutions was to wrap all bare declarations in a nested CSSStyleRule
with the nesting selector (&
). This idea was discarded for various reasons, including the following unwanted side-effects of &
desugaring to :is(…)
:
- It has an effect on specificity. This is because
:is()
takes over the specificity of its most specific argument. - It does not work well with pseudo-elements in the original outer selector. This is because
:is()
does not accept pseudo-elements in its selector list argument.
Take the following example:
#foo, .foo, .foo::before {
width: fit-content;
background-color: red;
@media screen {
background-color: green;
}
}
After parsing that snippet becomes this in Chrome before 130:
#foo,
.foo,
.foo::before {
width: fit-content;
background-color: red;
@media screen {
& {
background-color: green;
}
}
}
This is a problem because the nested CSSRule
with the &
selector:
- Flattens down to
:is(#foo, .foo)
, throwing away the.foo::before
from the selector list along the way. - Has a specificity of
(1,0,0)
which makes it harder to overwrite later on.
You can check this by inspecting what the rule serializes to:
↳ CSSStyleRule
.type = STYLE_RULE
.selectorText = "#foo, .foo, .foo::before"
.resolvedSelectorText = "#foo, .foo, .foo::before"
.specificity = (1,0,0),(0,1,0),(0,1,1)
.style (CSSStyleDeclaration, 2) =
- width: fit-content
- background-color: red
.cssRules (CSSRuleList, 1) =
↳ CSSMediaRule
.type = MEDIA_RULE
.cssRules (CSSRuleList, 1) =
↳ CSSStyleRule
.type = STYLE_RULE
.selectorText = "&"
.resolvedSelectorText = ":is(#foo, .foo, .foo::before)"
.specificity = (1,0,0)
.style (CSSStyleDeclaration, 1) =
- background-color: green
Visually it also means that the background-color
of .foo::before
is red
instead of green
.
Another approach the CSS Working Group looked at was to have you wrap all nested declarations in a @nest
rule. This was dismissed due to the regressed developer experience this would cause.
Introducing the CSSNestedDeclarations
interface
The solution the CSS Working Group settled on is the introduction of the nested declarations rule.
This nested declarations rule is implemented in Chrome starting with Chrome 130.
The introduction of the nested declarations rule changes the CSS parser to automatically wrap consecutive directly-nested declarations in a CSSNestedDeclarations
instance. When serialized, this CSSNestedDeclarations
instance ends up in the cssRules
property of the CSSStyleRule
.
Taking the following CSSStyleRule
as an example again:
.foo {
width: fit-content;
@media screen {
background-color: red;
}
background-color: green;
}
When serialized in Chrome 130 or newer, it looks like this:
↳ CSSStyleRule
.type = STYLE_RULE
.selectorText = ".foo"
.resolvedSelectorText = ".foo"
.specificity = (0,1,0)
.style (CSSStyleDeclaration, 1) =
- width: fit-content
.cssRules (CSSRuleList, 2) =
↳ CSSMediaRule
.type = MEDIA_RULE
.cssRules (CSSRuleList, 1) =
↳ CSSNestedDeclarations
.style (CSSStyleDeclaration, 1) =
- background-color: red
↳ CSSNestedDeclarations
.style (CSSStyleDeclaration, 1) =
- background-color: green
Because the CSSNestedDeclarations
rule ends up in the CSSRuleList
, the parser is able to retain the position of the background-color: green
declaration: after the background-color: red
declaration (which is part of the CSSMediaRule
).
Furthermore, having a CSSNestedDeclarations
instance doesn’t introduce any of the nasty side-effects the other, now discarded, potential solutions caused: The nested declarations rule matches the exact same elements and pseudo-elements as its parent style rule, with the same specificity behavior.
Proof of this is reading back the cssText
of the CSSStyleRule
. Thanks to the nested declarations rule it is the same as the input CSS:
.foo {
width: fit-content;
@media screen {
background-color: red;
}
background-color: green;
}
What this means for you
This means that CSS nesting got a whole lot better as of Chrome 130. But, it also means that you might have to go over some of your code if you were interleaving bare declarations with nested rules.
Take the following example that uses the wonderful @starting-style
/* This does not work in Chrome 130 */
#mypopover:popover-open {
@starting-style {
opacity: 0;
scale: 0.5;
}
opacity: 1;
scale: 1;
}
Before Chrome 130 those declarations would get hoisted. You’d end up with the opacity: 1;
and scale: 1;
declarations going into the CSSStyleRule.style
, followed by a CSSStartingStyleRule
(representing the @starting-style
rule) in CSSStyleRule.cssRules
.
From Chrome 130 onwards the declarations no longer get hoisted, and you end up with two nested CSSRule
objects in CSSStyleRule.cssRules
. In order: one CSSStartingStyleRule
(representing the @starting-style
rule) and one CSSNestedDeclarations
that contains the opacity: 1; scale: 1;
declarations.
Because of this changed behavior, the @starting-style
declarations get overwritten by the ones contained in the CSSNestedDeclarations
instance, thereby removing the entry animation.
To fix the code, make sure that the @starting-style
block comes after the regular declarations. Like so:
/* This works in Chrome 130 */
#mypopover:popover-open {
opacity: 1;
scale: 1;
@starting-style {
opacity: 0;
scale: 0.5;
}
}
If you keep your nested declarations on top of the nested rules when using CSS nesting your code works mostly fine with all versions of all browsers that support CSS nesting.
Finally, if you want to feature detect the available of CSSNestedDeclarations
, you can use the following JavaScript snippet:
if (!("CSSNestedDeclarations" in self && "style" in CSSNestedDeclarations.prototype)) {
// CSSNestedDeclarations is not available
}