skip to content

In CSS, what does the :not() pseudo-class match, and how does :not(.a, .b) differ from :not(.a):not(.b)?

level: middleimportance: should knowfreq 45%

answer

  1. negation of the argument
  2. comma inside means or, then negate
  3. same as chaining the old way
  4. only negates its own compound
  5. argument specificity comes with it

basics

~10 s

:not() matches any element that does not match the selector given to it. With a comma-separated list, :not(.a, .b) matches elements carrying neither class — identical in effect to chaining :not(.a):not(.b), just written once.

solid answer

~50 s

`:not()` is the negation pseudo-class: it matches an element precisely when the element does **not** match its argument. Since Selectors Level 4 the argument is a full selector list, and the list is negated as a whole — `:not(.a, .b)` means "matches neither `.a` nor `.b`", which is exactly what the older chained form `:not(.a):not(.b)` expressed. The two are equivalent, so the modern form is simply less typing. The list is an *or* inside the negation, which trips people up: `:not(.a, .b)` is not "lacks at least one of them". Two other things matter in practice. First, `:not()` negates only the compound it sits in, so `div:not(.card) p` still matches paragraphs inside a `.card` as long as some other ancestor `div` qualifies. Second, its specificity comes from its most specific argument, so `:not(#main)` carries an ID's weight even though it names nothing you are targeting.

code

css · 13 lines
css
/* separators without a trailing one */
.menu > li:not(:last-child) {
  border-inline-end: 1px solid #ddd;
}

/* neither featured nor archived — the two forms are equivalent */
.item:not(.featured, .archived) { opacity: 1; }
.item:not(.featured):not(.archived) { opacity: 1; }

/* external links only */
a[href]:not([href^="/"], [href^="#"])::after {
  content: " \2197";
}

go deeper

for a junior

Know that :not() matches elements that do not match its argument, and be able to write li:not(:last-child) to skip the trailing separator. Qualify the selector rather than negating bare.

for a middle

Explain that the comma-separated argument is an or inside the negation, making :not(.a, .b) identical to :not(.a):not(.b), and that the negation covers only the compound it is attached to.

for a senior

Recognise the debugging trap in descendant chains and the specificity that an ID argument drags along, and know that an unparseable argument invalidates the whole rule — sometimes usefully as a support gate.

for a principal

Own it as a maintainability question: negation-heavy selectors encode absence, which is hard for a later reader to reason about, so decide when a positive class applied at the source is the cheaper contract for a large team.

## The basic semantics `:not()` is the negation pseudo-class. It matches every element that the selector inside it does not match. `p:not(.intro)` matches paragraphs without that class; `li:not(:last-child)` matches every list item except the final one — the standard way to drop a trailing separator. ```css li:not(:last-child) { border-bottom: 1px solid #ddd; } ``` Because negation is over the whole element set, an unqualified `:not(...)` matches far more than people expect. `:not(.a)` on its own matches `html`, `body`, `head` elements, and every wrapper in the tree. Qualify it — `p:not(.a)` — unless you genuinely mean everything. ## The argument list Selectors Level 3 allowed only a single simple selector inside `:not()`. Level 4 widened it to a **selector list**, and to complex selectors containing combinators. The multi-argument form shipped in Chrome 88 and Firefox 84 around the turn of 2021 and is available in all current browsers. The negation applies to the list as a whole: ```css /* matches elements carrying neither class */ .item:not(.featured, .archived) { opacity: 1; } /* exactly equivalent, the pre-Level-4 spelling */ .item:not(.featured):not(.archived) { opacity: 1; } ``` Read it as "not (A or B)", which by De Morgan's law is "(not A) and (not B)" — the chained form. The wrong reading, "lacks at least one of A and B", would match an element that has `.featured` but not `.archived`; it does not. Complex arguments are also allowed: ```css /* a link that is not inside a nav */ a:not(nav a) { text-decoration: underline; } ``` ## The scope trap This is the mistake that costs real debugging time. `:not()` negates the compound selector it is attached to — a single element — not the whole ancestor chain. ```html <div class="page"> <div class="card"> <p>Still red.</p> </div> </div> ``` ```css div:not(.card) p { color: red; } ``` The paragraph *is* styled, because `div.page` matches `div:not(.card)` and the paragraph is its descendant. The descendant combinator asks only whether *some* ancestor qualifies. "A paragraph with no `.card` ancestor anywhere" is a different question, and the plain descendant form cannot express it — you would need a relational approach or a class applied at the point of exclusion. ## Specificity `:not()` itself adds nothing to the specificity count, but its argument does: the pseudo-class takes the specificity of its **most specific** argument. So `:not(.a)` weighs a class, `:not(#main)` weighs an ID, and `:not(p)` weighs a type. That is worth knowing before you reach for `:not(#something)` as a convenience, because you have just handed the rule ID-level weight that later rules must fight. ## Validity of the argument `:not()` is strict: if a browser cannot parse something inside it, the whole selector is invalid and the rule is dropped. That is the opposite of a forgiving list, and it is why the fallback-safe focus idiom `:focus:not(:focus-visible)` works — in a browser without support the rule vanishes rather than misfiring. Use that property deliberately when you want a rule to disappear on old engines, and avoid it when you were counting on partial application. ## Practical patterns ```css /* every item but the last: separators without a trailing one */ .menu > li:not(:last-child) { margin-inline-end: 1rem; } /* only decorate external links */ a[href]:not([href^="/"], [href^="#"]) { padding-inline-end: 1em; } /* reset spacing on the first block of a section */ section > *:not(:first-child) { margin-block-start: 1rem; } ``` The last one — often called the "owl" or stack idiom in its sibling-combinator form — is the most common production use: express spacing as "every element except the first" instead of adding a margin and then removing it again. ## Summary for the interview Say four things and you have covered it: `:not()` negates its argument; a comma-separated list is negated as a unit, equivalent to chaining; the negation applies to one compound, not to the ancestor chain; and the specificity of the most specific argument comes along with it.

  • What is the specificity consequence of writing .btn:not(#legacy) instead of .btn:not(.legacy)?
    `:not()` inherits the weight of its most specific argument, so the first version carries an ID's weight while the second carries a class's. The first rule then outranks almost anything an author writes later, which makes overriding it painful — a real cost for what was only meant to be an exclusion.
  • Does div:not(.card) p style a paragraph nested inside a .card that itself sits inside another div?
    Yes. The descendant combinator only requires that *some* ancestor match `div:not(.card)`, and the outer `div` does. The negation constrains a single compound, never the whole chain, so "a paragraph with no `.card` ancestor at all" is a different query that this selector cannot express.
  • What happens if one argument in a :not() list is unparseable to the browser?
    The entire selector is invalid and the rule is dropped — `:not()` is not forgiving. That is occasionally useful: `:focus:not(:focus-visible)` disappears on an engine that does not know `:focus-visible`, so the focus ring survives instead of being removed by a rule that only half-applied.

saying these in an interview costs you the question

  • Reads :not(.a, .b) as lacking at least one class
  • Thinks :not() excludes the whole ancestor chain
  • Assumes :not() always adds zero specificity
  • Believes only a single simple selector is allowed
  • Uses an unqualified :not() and is surprised html matches

context