DEV Community

Kilian Valkhof
Kilian Valkhof

Posted on • Originally published at kilianvalkhof.com

The gotchas of CSS Nesting

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;
}

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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 {
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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

:is(main, #intro) div { ... }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

body {
    background: blue;
}
@media all {
    :is(body) {
        background: red;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now from this CSS, it makes more sense that red wins. While it has the same specificity, 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.

Top comments (0)