Beware of Selector Nesting in Sass

Kitty Giraudel
Share

A couple of days ago, I tweeted about how I thought selector nesting actually caused more problems than it solved.

Some people agreed, some did not. In any case, it raised a couple of interesting thoughts so I thought I would put together an article to talk about the topic.

What is selector nesting?

Selector nesting is a feature of CSS preprocessors, allowing authors to nest selectors within selectors in order to create shortcuts. For instance:

.parent {
  color: red;

  .child {
    color: blue;
  }
}

Which would compile to the following CSS:

.parent {
  color: red;
}

.parent .child {
  color: blue;
}

In this example, .child is nested within .parent in order to avoid repeating the parent selector.

While this can be quite useful, I feel like this feature is being largely overused to the extent that we now try to solve issues we inflicted ourselves while nesting too much.

What’s wrong with nesting?

In itself, there’s absolutely nothing wrong with nesting. The feature makes sense. The problem, as is often the case, is not the feature but how we use it. Allow me to start with a couple of examples I stumbled upon.

There is this one from Micah Godbolt:

.tabs {
  .tab {
    background: red;

    &:hover {
      background: white;
    }

    .tab-link {
      color: white;

      @at-root #{selector-replace(&, '.tab', '.tab:hover')} {
        color: red;
      }
    }
  }
}

Or this one from ZI Qiu:

.root {
  width: 400px;
  margin: 0 auto;

  .links {
    .link {
      display: inline-block;

      & ~ .link {
        margin-left: 10px;
      }

      a {
        padding: 10px 40px;
        cursor: pointer;
        background: gray;

        &:hover {
          background: blue;
          color: white;
          font-size: 700;
        }

        .icon {
          margin-right: 5px;
          @include selector-modifier(-2 ':hover', 1 suffix '.zh') {
            color: red;
            background: green;
          }
          @include selector-modifier(-2 ':hover', 1 suffix '.en') {
            color: yellow;
            background: green;
          }
        }
      }
    }
  }
}

Let’s start with a disclaimer: This is clever code. In no way am I saying that this is bad code. I assume this code is doing exactly what it is intended to do.

Now, what if I asked you what exactly are those two examples meant to do? Base on a first glance, without spending a couple of minutes looking at the code, would you be able to guess?

Me neither. Because it is complicated.

Nesting makes code complicated

Both of the examples shown above use some selector functions (from Sass 3.4) to partially rewrite the current selector context (&).

So if I’m not mistaken, both examples involve more code in order to write less code, all of this in addition to some extra complexity. What about writing some simple code from the start?

This is a statement I already made before: Selector functions are not meant for everyday use. I believe Chris Eppstein and Natalie Weizenbaum explicitly said they were essentially adding this feature to help framework developers.

Note: if you find a legit use case for selector functions, that actually solves a problem, please be sure to show me, as I’d be interested in taking a look at that.

The Reference selector is ambiguous

The reference selector in Sass (&) can sometimes be quite ambiguous. Depending on the way it is used, it can yield a totally different CSS output. Here is a collection of simple examples.

/* SCSS */
.element {
  &:hover {
    color: red;
  }
}

/* CSS */
.element:hover {
  color: red;
}
/* SCSS */
.element {
  &hover {
    color: red;
  }
}

/* CSS */
.elementhover {
  color: red;
}
/* SCSS */
.element {
  & .hover {
    color: red;
  }
}

/* CSS */
.element .hover {
  color: red;
}
/* SCSS */
.element {
  &-hover {
    color: red;
  }
}

/* CSS */
.element-hover {
  color: red;
}
/* SCSS */
.element {
  &.hover {
    color: red;
  }
}

/* CSS */
.element.hover {
  color: red;
}
/* SCSS */
.element {
  .hover& {
    color: red;
  }
}

/* Syntax Error */
Invalid CSS after ".hover": expected "{", was "&"

"&" may only be used at the beginning of a compound selector.
/* SCSS */
.element {
  &:hover & {
    color: red;
  }
}

/* CSS */
.element:hover .element {
  color: red;
}
/* SCSS */
.element {
  &:hover {
    & {
      color: red;
    }
  }
}

/* CSS */
.element:hover {
  color: red;
}

And we are keeping things simple here with a single selector. Needless to say, it can get quite complicated when you multiply the references in a single rule.

The thing is, some operations work, some don’t (notice the error displayed in one of the examples above). Some generate a compound selector, some don’t. Depending on what’s being done and the Sass background of the next developer to use the code, it can become quite difficult to debug this kind of thing.

Unsearchable selectors

At this point I am mostly nitpicking. But there is something I don’t like that involves Sass code extensively using nesting to build BEM-like selectors:

.block {
  /* Some CSS declarations */

  &--modifier {
    /* Some CSS declarations for the modifier */
  }

  &__element {
    /* Some CSS for the element */

    &--modifier {
      /* Some CSS for the modifier of the element */
    }
  }
}

Before moving on to why I don’t like this, let me remind you what this code will compile to:

.block {
  /* Some CSS declarations */
}

.block--modifier {
  /* Some CSS declarations for the modifier */
}

.block__element {
  /* Some CSS for the element */
}

.block__element--modifier {
  /* Some CSS for the modifier of the element */
}

On one hand, this avoids repeating .block in each selector, which can be a pain when you have block names like .user-profile.

On the other hand, it creates new selectors out of nowhere that are thus impossible to search for. What if a developer wants to find the CSS from .block__element? Chances are high he’ll try to search it in the project from his IDE, finding nothing because this selector has never been authored as is.

Actually, it looks like I’m not the only one to hold this view. Kaelig, who was working at The Guardian at the time, tweeted this:

Also, I think it’s nicer to have the base name repeated. This way, it is crystal clear what’s going on.

I should note that source maps can help with this to some extent, but they don’t change the fact that the code base itself is still unsearchable.

When is it okay to use nesting?

As long as you and your team feel okay with the extra complexity it can involve, it is always okay to use selector nesting. If everybody’s fine with it, then that’s awesome!

If you ask me now, I feel like adding pseudo-classes and pseudo-elements are pretty much the only case where it’s fine. For instance:

.element {
  /* Some CSS declarations */

  &:hover,
  &:focus {
    /* More CSS declarations for hover/focus state */
  }

  &::before {
    /* Some CSS declarations for before pseudo-element */
  }
}

This is the best use case for selector nesting I can think of. Not only does it avoid repeating the same selector, but it also scopes everything from this element (states and virtual children) in the same CSS rule set. Also, & is not ambiguous: it means .element, no more, no less.

Another case where I do think nesting is interesting is when you want to apply some custom styles to a simple selector based on the upper context. For instance, when using Modernizr’s CSS hooks:

.element {
  /* Some CSS declarations */

  .no-csstransforms & {
    /* Some CSS declarations when CSS transforms are not supported */
  }
}

In this scenario, I feel like this is clear enough that .no-csstransforms & is intended to contextualize the current selector whenever CSS transforms are not supported. Although, this could be on the borderline of what I would consider acceptable use.

What are my recommendations?

Write simple CSS. Let’s take Micah’s example, which is slightly complicated, but not so complicated that it would be a pain to re-write.

This is the current code, obviously meant to style some tabs:

.tabs {
  overflow: hidden;

  .tab {
    background: red;

    &:hover {
      background: white;
    }

    .tab-link {
      color: white;

      @at-root #{selector-replace(&, '.tab', '.tab:hover')} {
        color: red;
      }
    }
  }
}

We could instead write it like this:

.tabs {
  overflow: hidden;
}

.tab {
  background: red;

  &:hover {
    background: white;
  }
}

.tab-link {
  color: white;

  .tab:hover & {
    color: red;
  }
}

I can’t think of a single reason for someone to prefer the first code snippet. Not only is it longer, but it’s also less explicit and makes use of Sass features that are not necessarily known by all developers.

Note that the CSS output is not exactly the same because we also simplified the code. Rather than having selectors as long as .tabs .tab .tab-link, we moved to easier things like .tab-link.

At this point, it’s not so much selector nesting but naming conventions and selector methodology. When using BEM, for instance, you name things after what they are rather than where they are, which often results in simple selectors (here simple means not compound), meaning there is no nesting involved.

According to CSS Guidelines from Harry Roberts:

It is important when writing CSS that we scope our selectors correctly, and that we’re selecting the right things for the right reasons […] Given the ever-changing nature of most UI projects, and the move to more component-based architectures, it is in our interests not to style things based on where they are, but on what they are.

A good rule of thumb when it comes to CSS selectors is the shorter the better. Not only is is better at any level (performance, simplicity, portability, intent, etc.), but it turns out it’s much easier not to screw things up with nesting when selectors are short.

Final thoughts

Whenever I say how I think selector nesting isn’t such a good idea, people always tell me “I never had an issue”, “we use it every day, no problem”, “it’s because you’re doing crazy things with it”. Of course there is nothing wrong with the feature.

Preprocessors don’t output bad code, bad developers do. And here, it’s not even about the output but about the input. Code becomes less and less readable when we add extra layers of complexity. Nesting selectors is one of those layers.

We were given nesting, so we abused its power. Then we were given selector functions, so we used those in order to fix the mess we made with nesting in the first place. This is wrong.

Use Sass, or any preprocessor for that matter, to simplify your code, not to make it more complex.

This article has been translated into French on La Cascade. Merci, Pierre!