Kilian Valkhof

Building tools that make developers awesome.

The gotchas of CSS Nesting

CSS & HTML, 13 June 2023, 3 minute read

I've written before about the problems you can run into with CSS nesting (keep in mind that article uses an older syntax but the point still stands) and the question that @ChallengeCSS tweeted out today made me realize there's actually a few more gotcha's. Here's what they tweeted:

Everyone is exited about CSS Nesting but are you ready for it? Answer the below quiz 👇

What would be the result of the following code (without cheating! 😈)

“`
body {
@media all {
background: red;
}
background: blue;
}
“`

No spoil in the comments!#CSS

— T. Afif @ CSS Challenges (@ChallengesCss) June 13, 2023

Take a moment to come up with your own answer (or vote), then read on.

Now, I initially got it wrong. Here was my thinking pattern:

  1. @media doesn't add specificity, so both declarations have a specificity of 0,0,1
  2. background: blue comes later, so it wins

But no, the background is red! It turns out that has to do with the way browsers transform your nested CSS rules to individual rules it can apply. So lets dive into how browsers do that.

A related gotcha, :is()

Last week was CSS Day (which was amazing) and of course a bunch of the presentations mentioned CSS Nesting. Unfortunately, some had a simplified explanation of how rules get resolved.

The & in nested CSS isn't just replaced by the ancestor, which is what you might think, but it's ancestor is also wrapped in :is():

body {
    & div {
        ...
    }
}

/* Doesn't become this: */
body div { ... }

/* It becomes this: */
:is(body) div { ... }

Now that doesn't sound like there is much of a difference between body div and :is(body) div, indeed both have a specificity of 0,0,2, but remember that:is() takes on the highest specificity of the selectors in it. So when you have the following:

main, #intro {
    & div {
        ...
    }
}

The resulting selector, even when targeting a div in main, ends up as:

:is(main, #intro) div { ... }

Which makes it go from 0,0,1 for main div to 1,0,1 making it vastly more specific. That gotcha gets lost when examples fail to include the way ancestors are wrapped in :is() (and yes, they also nest :is()!)

Back to the original gotcha

So back to the challenge up top. You can intermingle properties and nesting. You shouldn’t to keep your code readable, but the following CSS works just fine and applies all the styling:

body {
  filter: blur(5px);
  
  @media all {
    background: red;
  }
    
  background: blue;
  
  @media all {
    color: deeppink;
  }
  
  rotate: 20deg;
}

It's when the browser parses this CSS into individual rules that the sneaky thing happens:

  1. The browser adds a new ruleset, body, and starts adding the properties to it/
  2. The browser then adds another new ruleset for the nested media query and starts adding its properties to it/
  3. When it exits the nested media query, it adds the rest of the properties to the original ruleset again until that is exited.

So if we look at this CSS again:

body {
    @media all {
        background: red;
    }
    background: blue;
}

That actually resolves to these two rules in this specific order:

body {
    background: blue;
}
@media all {
    :is(body) {
        background: red;
    }
}

Now from this CSS, it makes more sense that red wins. It has the same specificity but it comes after the first rule so it wins. And that's the gotcha.

This post originally claimed that only @-rules could be intermingled with properties but this was incorrect. Thanks @ChallengesCSS for correcting me.

Polypane browser for responsive web development and design Hi, I'm Kilian. I make Polypane, the browser for responsive web development and design. If you're reading this site, that's probably interesting to you. Try it out!