Styling Elements by Exclusion with the CSS :not() Pseudo-Class

The :not() pseudo-class excludes elements matching its argument from a rule, letting you write inclusive selectors without separate override declarations.

You have a list of cards. Every one of them needs a border except the last one in the row. You write a rule that puts a border on all of them, then you write a second rule to strip it off the last one. That works, but it is a band-aid over a misunderstanding. The CSS :not() selector exists to express exclusion directly in the rule itself, so you never need the override. Write li:not(:last-child) { border-bottom: 1px solid #ccc; } and the border applies to every list item except the final one, in one pass, with no follow-up rule. That is the whole point of the functional pseudo-class: it inverts a selector list, and the cascade stays clean because you are not fighting source order with a second declaration.

The pattern this replaces is everywhere. You style a container with margin-bottom, then you write .container:last-child { margin-bottom: 0; } to undo it. Or you give every button a background, then you special-case the submit button with a heavier selector. Those overrides are not just extra bytes. They are places where the cascade can bite you later. When someone adds a wrapper div, the :last-child rule stops matching, and the override silently disappears. The negation approach keeps the exclusion attached to the condition that defines it, so the rule stays true even when the DOM shifts. Use it for any exclusion that is a selector-shaped condition: not the last child, not the first, not the one with a class, not the one inside a specific parent.

Specificity Is the Argument's, Not the Pseudo-Class's

There is a persistent myth that :not() adds zero specificity, so it is a free way to override anything. That is wrong, and it is the kind of mistake that produces baffling bugs. In Selectors Level 4, the specificity of a negation rule is the specificity of the most specific selector inside it. Write :not(#header) and the rule has ID-level specificity, because #header is an ID selector. Write :not(.btn) and it has class-level specificity. The pseudo-class itself contributes nothing, but the inner selector's weight is entirely transferred to the rule.

So p:not(.intro) and p.intro have equal specificity. The one that comes later in source order wins. If you expect the negation to be a low-specificity escape hatch, you will lose that fight every time.

Chained Negation for Multiple Conditions

The real power shows when you chain it to exclude several conditions at once. Suppose you have a navigation bar with links, and you want to style all of them except the current page link, the external link, and the one that is hidden. You could write three separate rules and three overrides. Or you could write one selector: nav a:not(.current):not([rel="external"]):not(.visually-hidden) { color: #333; }. Each chained pseudo-class adds a condition, and the element must fail all of them to match. This replaces JavaScript-based class toggling for exclusion logic. You are not programming the exclusion. You are declaring it. The browser does the filtering in the selector engine, and the rule stays declarative.

Chained negation selectors answer the common question: how do I exclude several distinct things at once? The trick is that each function is separate, and they combine like any other compound selector. p:not(.a):not(.b) matches a paragraph that has neither class. It is not the same as p:not(.a, .b), which is also valid in Selectors Level 4 but means something subtly different. The comma-separated list inside a single negation says “exclude if the element matches any of these,” which is exactly what you want for a set of alternatives. The chained form is for when the conditions must all be absent independently. Choose based on whether the exclusions are a union or an intersection. For a union, use the selector list. For an intersection of absences, chain the functions.

Forgiving Selector Lists

The selector list inside the negation is forgiving, which is a relief in production code. In a forgiving selector list, if one selector in the list is invalid or unsupported, the browser drops that one and keeps the rest. That behaviour shipped across Blink, WebKit, and Gecko engines as part of the Interop 2024 focus area. It means you can write :not(.foo, :unsupported-pseudo) and the rule still applies to elements that are not .foo, because the unsupported part is ignored. That is not true for a non-forgiving list like a regular selector list, where one bad selector invalidates the whole rule. The practical effect is that the negation pseudo-class is safer to use in progressive enhancement than you might expect. Your fallback is the @supports selector(:not(*)) guard, which lets you check that the browser understands the syntax before you rely on it.

Example: Chained Exclusion on a List

Here is a complete example you can run in any modern browser. It styles all list items except the last one, and it uses a chained negation to also skip the item with the hidden class. The rule needs no override and no JavaScript.

ul.menu li:not(:last-child):not(.hidden) {
  border-bottom: 1px solid #ddd;
  padding-bottom: 0.5rem;
  margin-bottom: 0.5rem;
}

That single declaration replaces two rules and a script that toggles a class. The browser evaluates the selector once, and the exclusion is built into the matching logic.

Example: Selector List for a Union of Exclusions

A second example shows the selector list form, which is the more compact way to exclude a union of conditions. This one applies a background to every card except the one with the featured class and the one with the out-of-stock class.

.product-card:not(.featured, .out-of-stock) {
  background: #f9f9f9;
}

The comma inside the negation is the forgiving selector list. If you later add a pseudo-class that some old engine does not support, the list still works for the supported parts. This is the pattern that replaced the older technique of writing a broad rule and then a higher-specificity override for the exceptions.

Example: Compound Selector Inside the Negation

The third example shows how the negation interacts with compound selectors. You can exclude an element that has a class AND an attribute, but you need the compound selector as the inner value, not two separate calls. The distinction matters: :not(.active[aria-expanded="true"]) excludes only elements that have both, while :not(.active):not([aria-expanded="true"]) excludes elements that have either. Choose the compound form when the exclusion is a single precise condition.

button:not(.active[aria-expanded="true"]) {
  opacity: 0.7;
}

That rule dims every button except the one that is both active and expanded. It is a single exclusion, expressed as one compound selector inside the function.

When the Guard Is Necessary

The failure case is when the selector list argument is invalid in a way that is not forgiven. In Selectors Level 4, if you pass an unsupported pseudo-class that is not part of a forgiving list, the entire rule is dropped. That is the non-forgiving behaviour, and it is why the @supports guard exists. You can test for the exact feature you need: @supports selector(:not(.complex .selector)) checks that the engine can handle a complex selector as the inner value. If it fails, you fall back to the old technique of duplicate rules with overrides. That guard is the safety net for browsers that shipped the negation with only simple selector support. Safari 9 predates the complex selector list support that arrived in Safari 16.4.

Keep the Inner Selector Simple

The specificity rule is the one that trips up most people. The negation takes the specificity of its inner selector, and the pseudo-class itself adds zero. That means :not(#footer) has the same weight as #footer, and it will beat any class-based rule regardless of source order. The practical lesson is to keep the inner selector as simple as possible. If you write :not(.container .item) to exclude a descendant, the specificity is that of the two-class compound, which is (0,2,0). That is often more than you need and can cause cascade conflicts. Prefer a simpler selector like :not(.item) and accept that the exclusion is broader. If you need the descendant exclusion, use it deliberately and know the weight you are carrying.

What the Selector List Accepts

The fact sheet is clear: the negation accepts a selector list per Selectors Level 4, and that list can contain complex selectors, not just simple ones. The older Selectors Level 3 restriction limited the inner value to a single simple selector, which is why older tutorials show :not(.foo) but never :not(.foo .bar). Modern engines all support the complex selector list. Baseline marks it widely available. If you are targeting browsers older than that, the @supports selector(:not(*)) guard tells you whether the feature is present. The guard is cheap and reliable, and it lets you write the modern syntax without worrying about legacy engines.

Four Questions That Come Up Every Time

Does the negation increase specificity? No, it does not add any specificity of its own. It uses the specificity of its most specific inner selector. So :not(.class) is (0,1,0), and :not(#id) is (1,0,0). The pseudo-class itself contributes zero.

Can I use a selector list inside it? Yes, in all modern browsers. The list is forgiving, meaning an unsupported selector in the list is dropped rather than invalidating the whole rule. This is part of Selectors Level 4 and shipped in the Interop 2024 effort.

What is the difference between :not(.a, .b) and :not(.a):not(.b)? The first excludes elements that match either .a or .b. The second excludes elements that match both. They are not interchangeable.

How do I exclude an element that is not a direct child? Use a complex selector inside the value, like :not(.parent .child). This works in modern browsers and is covered by the @supports selector() guard for older ones.

Applying Exclusion Without Overrides

The most common use is the :not(:last-child) pattern, which appears in every list and grid. It is the direct replacement for the override that strips a border or margin from the final item. The selector is simple, the specificity is (0,1,0) because :last-child is a pseudo-class, and it works everywhere. A second common use is excluding a state class, like :not(.disabled), to style enabled form controls. A third is excluding an element with an attribute, like :not([hidden]), to show content that is not hidden. Each of these is a single rule with no follow-up override, and each is easier to read than the two-rule pattern it replaces.

The Cost of Getting It Wrong

The failure mode that costs you the most is the specificity trap. You write :not(.btn) to exclude buttons from a rule, but the rule also matches a link that happens to have no class. The link gets the style, and you spend an hour debugging why. The fix is to be precise about what you exclude. Use a:not(.btn) if you only mean links, and button:not(.btn) if you only mean buttons. The universal selector inside :not(*) is valid but useless, because it matches nothing. The specificity of :not(*) is (0,0,0), so it adds no weight, but it also excludes nothing. Avoid it unless you are deliberately testing the parsing behaviour.

Who Should and Should Not Use This

This selector suits the front-end developer who writes CSS daily and wants to reduce override chains. It suits the designer who has fought the cascade and wants a declarative way to say "everything except this." It suits the educator who needs a precise, sourced explanation of how negation works in the selector engine. It does not suit the person learning CSS for the first time who has not yet internalised the cascade. Start with the basics of specificity and source order before touching negation. It does not suit the JavaScript developer looking for a way to filter elements in a script. That is a DOM problem, not a CSS problem. For the right developer, the negation pseudo-class is the difference between a style sheet that fights itself and one that expresses intent directly.