← All writing
Craft · · 7 min

:not():not()

On De Morgan's law in a stylesheet, specificity that's computed rather than matched, and why :not(header) a is a lie.

CSS

There’s a selector in a stylesheet I own that reads .card:not(.card--flat):not(.card--ghost):not(.is-loading), and I wrote it, and at the time it genuinely felt like diligence. Each :not() was a real case I’d thought about. Lining them up felt like being thorough.

It isn’t wrong, exactly. That’s sort of the problem with this whole family of selectors. They mostly do what you wanted, so you never go back and check what they actually said.

Three ways to say no, two of which agree

Here’s the bit that reorganized my head a little. These are the same selector:

buttons.css
.btn:not(.btn--ghost):not(.btn--link) { }   /* 0,3,0 */
.btn:not(.btn--ghost, .btn--link)     { }   /* 0,2,0 */
CSS

Same elements. Every time. :not() with a selector list matches an element that matches none of them, which is exactly what chaining gets you, because “not A and not B” and “not (A or B)” are the same sentence. It’s De Morgan’s law wearing a hat.

The difference is what you’re charged. A chained pseudo-class contributes its own weight each time it appears, so three :not()s with a class inside each is three classes of specificity. But a selector list inside :not() is scored once, at whatever its most specific branch is. So the comma version is one class no matter how many things you put in it.

Same elements, every time. Only one of them charges you for it.

And I know specificity inflation sounds like a tidiness complaint. It isn’t, really. It’s that six months later somebody needs one special button, they write .btn--special, it loses to your 0,3,0 for reasons no one can see from where they’re sitting, and they do the only thing available and add !important. You built that. You built it by being careful.

The one that isn’t the same is :not(.a.b), a compound with no comma, which excludes only the elements carrying both classes at once. So the three forms are two different selectors, and the odd one out is the one that looks the most like the other two. If you want “neither,” comma. If you want “not both,” compound. I still have to say it out loud every time.

(You can nest them now, too. :not(:not(.a)) is valid and means .a with extra steps. I haven’t found a use for it, but I enjoy that it’s there.)

Specificity is computed, not matched

This one I resisted for a while because it felt like it had to be a bug.

Both :is() and :not() take the specificity of the most specific thing in the list, and that’s decided when the selector is parsed, not when it matches. Which branch actually hit is irrelevant.

:is(h1, #app) p { }   /* 1,0,1 for every paragraph it ever touches */
CSS

So a paragraph under a plain h1, in a document that has no #app and never did, is styled by an ID-weight rule. One ID anywhere in the list poisons every branch. Same for :not(#legacy, .modern), which is an ID-weight negation even though the thing you were mostly thinking about was the class.

:where() is the escape hatch and it’s a clean one: identical matching to :is(), specificity of zero, always, unconditionally. Which means you can stack it with :not() for a negation that costs nothing at all:

links.css
a:not(:where(.btn, .tag, .site-nav a)) {
  text-decoration: underline;   /* 0,0,1. still just an element. */
}
CSS

:not() inherits its argument’s specificity, :where() has none to give, so the whole exclusion list is free. That’s the single most useful thing I know about these pseudo-classes and it took me embarrassingly long to put together, because I’d learned :where() as “the low-priority one for resets” and filed it there.

:not(header) a is a lie

Okay, this is the one that actually cost me an afternoon.

:not(header) a { color: hotpink; }
CSS

Read that out loud and it sounds like “links that aren’t in the header.” It is not. It’s “a link with at least one ancestor that isn’t a header,” and a descendant combinator only needs one ancestor to match. Your link inside the header has a nav above it. It has a body above it. Neither of those is a header. You have a body. Every link on the page qualifies, the negation does nothing whatsoever, and the rule works perfectly on your test page because your test page didn’t have a header link in it.

Complex selectors inside :not() are a Level 4 thing and they work everywhere I build for now, so a:not(header a) is a real answer rather than a spec fantasy. But honestly, half the time I hit this I stop and reconsider whether I wanted a global rule with a carve-out or just two scoped rules. Usually it’s two scoped rules. Usually the carve-out was me trying to be clever about not repeating myself, and there are worse things than repeating yourself.

One typo, whole rule gone

Selector lists are unforgiving by default. If any part of a comma-separated selector is invalid, the browser throws out the entire rule, not the broken branch. This is ancient behavior and it’s why the old vendor-prefixed placeholder selectors had to be written as separate rules.

:is() and :where() changed that for their own arguments: their lists are forgiving, so a branch the browser doesn’t understand gets dropped and everything else survives. :not() did not. Its list is unforgiving, and that’s deliberate rather than an oversight. It’s what lets a selector inside the parens be a meaningful feature test rather than something silently swallowed.

Which produces a nasty asymmetry. Fat-finger a selector inside :is() and you get a slightly wrong rule. Fat-finger the same thing inside :not() and you get no rule, no error, no red anything, just a declaration block that has decided not to exist. If a rule is inexplicably doing nothing at all rather than doing the wrong thing, look inside the parens first.

And while I’m here: :nth-child filters after

Not a negation, but the same shape of misreading, so it lives in the same drawer in my head.

.item:nth-child(2) does not mean “the second .item.” It means “the second child, and also it has to be an .item.” The position is computed against every sibling, then your class is checked, and if the second child happens to be a heading you get nothing. Which is fine until somebody adds a wrapper or a <hr> and your styling silently walks one seat over.

:nth-child(2 of .item) is the one that means what you originally wanted. It filters first, then counts. It’s been in Safari roughly forever and landed in the other two a few years back, and I keep forgetting it exists.

Anyway, go grep your stylesheets for ):not( and see what turns up, because I’d bet most of those chains want to be commas and were only ever chains because that’s how we all learned it in 2016. It’s a five-second edit that takes a class off your specificity and changes nothing visually 🙃.

I do still have an !important in production right now, defending against a selector I wrote myself. So take the above as a note rather than a sermon.

Read similar posts
6 min

Style queries

Five surface variants, four child elements, and 20 selectors that existed only to tell a child which parent it was in. Style queries delete the whole grid of them. That isn't quite the same as saying you needed them.

6 min

currentColor

I opened a button component with 11 color declarations in it, across four variants, and the correct number was one per variant, because six of those properties already default to the thing they were being set to.