CSS Nesting Gets a Fix for Interleaved Declarations

CSS nesting has carried a subtle parsing quirk since its rollout: when you place plain declarations after a nested rule, browsers that lack the CSSNestedDeclarations interface hoist those declarations to the top of the style rule. The CSS Working Group has now addressed this by adding CSSNestedDeclarations to the CSS Nesting Specification. Chrome 130 ships the fix, with Firefox Nightly 132 and Safari Technology Preview 204 ready for testing.

Bramus

The Original Parsing Problem

Consider a rule where a declaration follows a nested media query:

.foo {
    width: fit-content;

    @media screen {
        background-color: red;
    }
    
    background-color: green;
}

Intuitively, the background-color: green; declaration should win because it comes last. In Chrome before version 130, that wasn't the case—the element rendered with a red background. Inspecting the parsed rule in older Chrome shows why:

.foo {
    width: fit-content;
    background-color: green;

    @media screen {
        & {
            background-color: red;
        }
    }
}

Two things happened during parsing:

  • The background-color: green; declaration was moved up to join the other declarations at the top of the rule.
  • The nested CSSMediaRule got its declarations wrapped in an extra CSSStyleRule using the & selector.

You can verify this yourself by reading the cssText property from the CSSStyleRule in your browser's console.

Why the Rewrite Happened

The root cause lies in how the CSS Object Model (CSSOM) represents a style rule. A CSSStyleRule has two relevant properties:

  • The style property, a CSSStyleDeclaration holding the rule's declarations.
  • The cssRules property, a CSSRuleList containing nested rules.

Because every declaration in the snippet was funneled into the style property, the original position of background-color: green—after the media rule—was lost:

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: green
  .cssRules (CSSRuleList, 1) =
    ↳ …

CSS engines need to distinguish declarations that appear at the start of a rule from those interspersed with nested rules. The inner rewrite of the media query's declarations into a CSSStyleRule was a workaround for the fact that CSSMediaRule wasn't designed to hold declarations directly.

↳ 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

Discarded Solutions

The CSS Working Group weighed several approaches before settling on CSSNestedDeclarations. One proposal was to wrap bare declarations in a nested CSSStyleRule with the & selector. That idea was rejected because & desugars to :is(…), which brings two serious side effects:

  • It alters specificity, since :is() takes the specificity of its most specific argument.
  • It breaks with pseudo-elements in the outer selector, since :is() cannot accept pseudo-elements in its selector list.

Take this example:

#foo, .foo, .foo::before {
  width: fit-content;
  background-color: red;

  @media screen {
    background-color: green;
  }
}

In Chrome before 130, it parsed to:

#foo,
.foo,
.foo::before {
  width: fit-content;
  background-color: red;

  @media screen {
    & {
      background-color: green;
    }
  }
}

The nested rule with & flattens to :is(#foo, .foo), dropping .foo::before entirely and gaining a specificity of (1,0,0). The visual result is that .foo::before gets a red background rather than green.

Another proposed approach—requiring developers to wrap declarations in an explicit @nest rule—was dismissed for the poor developer experience it would create.

The CSSNestedDeclarations Solution

The CSS Working Group ultimately introduced a nested declarations rule. The parser now wraps consecutive directly-nested declarations in a CSSNestedDeclarations instance, which serializes into the cssRules property of the parent CSSStyleRule.

Taking the same style rule as before:

.foo {
  width: fit-content;

  @media screen {
    background-color: red;
  }
    
  background-color: green;
}

In Chrome 130 and newer, serialization preserves the original layout:

↳ 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

The position of background-color: green is now retained—it stays after the media rule's red declaration. Better still, CSSNestedDeclarations carries no specificity penalty and works with pseudo-elements. Reading back the cssText confirms the input CSS survives round-trips unchanged:

.foo {
  width: fit-content;

  @media screen {
    background-color: red;
  }
    
  background-color: green;
}

Impact on Existing Code

The fix changes how browsers treat rules that interleave bare declarations with nested rules. Consider this pattern using @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, the opacity: 1; and scale: 1; declarations were hoisted into CSSStyleRule.style, leaving the @starting-style block alone in cssRules. Starting with Chrome 130, the declarations stay put, creating two nested rules in cssRules: the CSSStartingStyleRule followed by a CSSNestedDeclarations instance holding the declarations.

This ordering is critical. Because the declarations in the CSSNestedDeclarations instance come after the @starting-style rule, they overwrite its values, and the entry animation disappears. The fix is to place the @starting-style block after the regular declarations:

/* This works in Chrome 130 */
#mypopover:popover-open {
  opacity: 1;
  scale: 1;

  @starting-style {
    opacity: 0;
    scale: 0.5;
  }
}

As a rule of thumb, keeping all bare declarations on top and nested rules below them ensures compatibility with every browser that supports CSS nesting.

To detect support for the new interface in JavaScript:

if (!("CSSNestedDeclarations" in self && "style" in CSSNestedDeclarations.prototype)) {
  // CSSNestedDeclarations is not available
}