CSS nesting improves with CSSNestedDeclarations

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.

Browser Support

  • Chrome: 130.
  • Edge: 130.
  • Firefox: 132.
  • Safari: 18.2.

Source

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 extra CSSStyleRule 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 a CSSStyleDeclaration instance representing the declarations.
  • The cssRules property which is a CSSRuleList that holds all nested CSSRule 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.

Browser Support

  • Chrome: 130.
  • Edge: 130.
  • Firefox: 132.
  • Safari: 18.2.

Source

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
}