DEV Community

Paceaux
Paceaux

Posted on • Originally published at blog.frankmtaylor.com

Using :not()? Try NOT to...

CSS is full of little gotchas and head scratchers. It’s also got a land mine or two that’s all too easy to step on. One of those landmines is the :not() pseudo-class.

As useful as it may seem, I’d like to encourage you to not use it, unless you really, really mean to because of the side-effects it brings.

What does :not() do?

:not() is a pseudo-class. Like all pseudo-classes, it targets a state. The most common pseudo-classes we see in our stylesheets are :hover and :focus, but we do have other fun friends like :checked, :target, and everyone’s loneliest, :only-child.

In the case of :not(), the state that is being target is, well… a negative. It targets a state of not being something.

The goal of :not() is to exclude certain elements from a particular ruleset.

:not() tells the browser “is not this”

CSS selectors are parsed right-to-left— but when a pseudo class is “chained” to something, the best way to read it is by saying, “that is also”.

So we read the below selector as “every element with the class name article, that is also not an element with the class name article--news

.article:not(.article--news) {
  color: blue;
}

:not() also raises specificity unexpectedly

Pseudo-classes have the specificity of a class. Usually.

How a typical pseudo-class behaves

Styling a plain element (also known as a type selector) gets us a specificity of 0,0,1:

a {
  color: #6ea5dc;
}

Then we style the :hover pseudo-class of this link element, getting the specificity 0,1,1:

a:hover {
 color: #6ea5c1;
}

And if we wanted something special for targeting two states of this element, we’d get a specificity of 0,2,1:

a:hover:visited {
  color: #7fb6d2;
}

How :not() behaves may surprise you

Let’s suppose you wanted to target links that are not in a state of being visited. You might write this:

a:not(:visited) {
  color: #6ea5c1;
}

The specificity on this selector is 0,1,1.

Well that’s just like it was with a:hover. What’s the big deal?

:not() didn’t raise the specificity. :visited did.

It’s the argument within :not() that raises specificity

Let’s suppose you’ve got a special style for your calls-to-action. Maybe they’re autogenerated and you feel the need to make a single, one-off case of making it different. It might be tempting to write this:

 a:not(#call-to-action--3428){
  color: #333;
}

First, let’s read that out-loud (go ahead, I won’t judge you):

Every a element that is also not an element with the id call-to-action--3428

Every element. Got it?

Would you believe that the specificity of this style, on every a element is now 1,0,1 ?

:not() passes the specificity of the thing you’re excluding onto the thing your targeting.

I’ll say that one more time for emotional effect:

:not() passes the specificity of the element you’re excluding to the element you’re targeting.

Ugh.

Can’t we just set a best-practice; “don’t make the argument for :not() higher than the target?”

So we’re saying a:not(.nav__link) is off the table because this will target every link on the page and give it a specificity of 0,1,1; that’s an “unexpected” specificity.

What if the excluded specificity matches the targeted?

Isn’t this safe with a specificity of 0,2,0?

.nav__link:not(:last-child) {
  color: #444;
}

Let’s read this together:

Every element with the class name nav__link that also is not the :last-child

Every. Element.

Except one—and every element except that one got the specificity of 0,2,0.

You know how quickly this becomes a problem? Try the first time you add a hover state:

/*If this doesn't come later in the cascade, it's pointless*/
.nav__link:hover { 
  color: #555;
}

Ok, so can the excluded specificity be less than the target’s specificity?

ul .nav__link:not(:last-child) will get you a specificity of 0,2,1 on every .nav__link, except one. You probably didn’t mean to do that.

.nav__link:not(button)? Specificity of 0,1,1 on every nav__link. You probably didn't mean to do that, either.

The thing is, it doesn’t matter what the argument is that you send into :not(). Unless it’s *, it’s going to raise the specificity on the targeted item.

That’s right. This is the only thing that you can safely write:

.nav__link:not(*) { /* Pull Request denied*/
  color: transparent; /* double-denied */
}

Should you use :not() to set “default styles”?

I’ve seen this pattern a few times in my life. Folks write out a “default style” using :not():

.nav__item:not(:last-child) { /* 0,2,0*/
  border-bottom: 1px solid #333;
}

But the cost here is that theming is now more difficult. What should be a natural increase in specificity isn’t. The below styles only work if they come later in my stylesheet.

.nav--darkMode .nav__item { /* 0,2,0 */
  border-bottom: 1px solid #fff; 
}

So more than likely, I end up arbitrarily raising specificity (otherwise known as specificity bloat):

.nav--darkMode .nav__item.nav__item { /* 0,3,0 ...who hurt you?*/
  border-bottom: 1px solid #fff; 
}

But, if I opt for a less terse selector that has clearer intent:

.nav__item { /* 0,1,0 */
  border-bottom: 1px solid #333;
}

.nav__item:last-child { /* 0,2,0 */
  border-bottom: none;
}

We discover that theming can come without introducing dependencies on code organization:

.nav--darkmode .nav__item { /* 0,2,0 */
  border-bottom: 1px solid #fff;
} /* Pull Request Approved. Let's get beer. */

So when is :not() safe?

Rarely

This is really an article about selector intent. Most of the time we write code thinking about what we intend to style. Not what we don’t intend to style.

:not() is safest when you’ve reached an edge case. An edge-case is a point in your code where the scope is very small and you have a clear understanding that it won’t be expanded further.

A perfect example is picking up where we left off with a themed navigation item. In our last example, we got ourselves in a hole: we would have to remove border on the last-child again:

.nav--darkmode .nav__item:last-child { /* 0,3,0 */
  border-bottom: none:
}

That isn’t what we want. We don’t want to write code that makes us write more code.

Now, suddenly, :not() seems very useful:

.nav--darkmode .nav__item:not(:last-child) {
  border-bottom: 1px solid #fff;
}

We still raised the specificity (0, 3, 0) on our .nav__item. But we did it on an edge-case at the benefit of not having to rewrite a pre-existing style. We wrote our selector with a clear intent of, “I don’t want to overwrite :last-child except when I’m dealing with a .nav__item that’s within a .nav--darkmode container.”

Avoid :not(), but don’t ignore it

  • Avoid it for excluding type selectors (elements)
  • Avoid it for setting default styles
  • Never pass in an element with a higher specificity than your target
  • Prefer it in narrow scopes to address edge-cases
  • Prefer it when it saves you from duplicating code at a higher specificity

Discussion (0)