Topic 1.5
:not, :is, :where and :has
In one line
Four functional pseudo-classes add logic to selectors: :not() excludes, :is() and :where() say "any of these" (with full or zero specificity), and :has() selects an element by what it contains or what follows it, the long-awaited parent selector.
Think of it like this
Choosing players for a game. "Everyone except the goalkeeper" is :not(). "Anyone from class 5A or 5B" is :is(). "Anyone from 5A or 5B, but it's only a suggestion that any other rule can overrule" is :where(). "Every team that has a captain" is :has(): you pick the team by looking at who is in it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Functional pseudo-class
- A pseudo-class with brackets that takes selectors inside it, like
:not(.done). - Argument
- What you put inside the brackets of a function, like
.donein:not(.done). - Forgiving selector list
- A list where a broken selector is skipped instead of breaking the whole rule.
:is()and:where()are forgiving. - Specificity
- The score that decides which of two clashing rules wins. Higher score wins.
- Zero specificity
- A score of nothing: any other rule that matches the same element will beat it.
- Relative selector
- A selector inside
:has()that is read starting from the element being tested, like> imgor+ p. - Parent selector
- A way to pick an element because of what is inside it.
:has()is CSS's parent selector.
Step by step
01Exclude with :not()
Instead of styling everything and then undoing it for one element, say what you don't want.
Since 2021 every browser accepts a list inside :not(), so :not(.a, .b) means "neither a nor b".
.nav a:not(.active) {
opacity: 0.7;
}
li:not(:last-child) {
border-bottom: 1px solid #ddd;
}02Shorten lists with :is()
Without :is(), styling headings inside three containers needs nine selectors. With it, one line.
/* before */
article h2, article h3, aside h2, aside h3, section h2, section h3 { line-height: 1.2; }
/* after */
:is(article, aside, section) :is(h2, h3) { line-height: 1.2; }03Write overridable defaults with :where()
A reset written with :where() has zero specificity, so the plainest rule you write later still wins. Libraries use this to stay out of your way.
:where(ul[class], ol[class]) {
list-style: none;
padding: 0;
}
/* any later rule wins, even a bare type selector */
ul { padding-left: 20px; }04Compare specificity
:is() takes the score of its strongest argument; :where() takes none. This one difference is the whole reason both exist.
05Select by content with :has()
Style a card differently when it contains an image, a form when a field is invalid, or a label when its checkbox is ticked. Before :has() each of these needed JavaScript to add a class.
.card:has(img) {
display: grid;
grid-template-columns: 80px 1fr;
}
form:has(input:invalid) button {
opacity: 0.5;
}
label:has(input:checked) {
font-weight: 700;
}06Look forward and backward with :has()
Relative selectors inside :has() can start with a combinator. h2:has(+ p) selects a heading by the element after it, the "previous sibling" selector CSS never had.
h2:has(+ p) {
margin-bottom: 4px;
}
li:has(+ li.active) {
color: #6b7280;
}Try it yourself
- 1
Swap :is for :where
In the second example, change
:is(#featured, .card) h2to:where(#featured, .card) h2. Predict both card colours, then run: both turn seagreen, because the:where()rule now scores zero. - 2
Add a picture to the second card
In the third example, copy the
<svg>…</svg>into the second card. Predict the log line for cards with a picture (2), then run. - 3
Exclude one more type
In the first example, add
<input type="radio" name="r">to the form. Predict whether it gets the box style (no) and what the third log line prints (still 2).
Code & diagrams
Each rule says what to leave out, so you never have to undo styles with a second rule.
Preview
Console
Nothing logged yet.Expected console output
dimmed links: 2
items with a divider: 2
boxed inputs: 2Card B has no id, yet it is crimson: the `:is()` rule scores as an ID selector for every element it matches. The `:where()` rule loses even to a bare `li`.
Preview
Console
Nothing logged yet.Expected console output
Card A: rgb(220, 20, 60)
Card B: rgb(220, 20, 60)
List item: rgb(46, 139, 87)Tick or untick a box: the label restyles with no JavaScript, because `:has(input:checked)` is re-checked as the state changes.
Preview
Console
Nothing logged yet.Expected console output
cards with a picture: 1
ticked options: 1
headings before a paragraph: 1Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
:is() makes a rule unexpectedly hard to override
Write :is(#sidebar, .panel) a { color: crimson; } and later .panel .link { color: seagreen; } for links inside .panel.
Break #2
An unsupported selector inside :has() kills the rule
Write .card:has(img, :hovr) { border-color: blue; }.
Myth vs fact
Myth
:where() is just a newer name for :is().
Fact
They match the same elements, but :where() always has zero specificity and :is() takes its strongest argument's. Pick based on whether the rule should be easy to override.
Myth
:has() is too slow to use.
Fact
Browsers optimised :has() heavily before shipping it. Keep the argument specific (.card:has(> img) rather than :has(*) on body) and it is fine for normal pages.
Myth
:not(.a .b) is not allowed.
Fact
Since Selectors Level 4 (shipped in every browser by 2021), :not() accepts complex selectors and lists, such as :not(.a .b) and :not(.x, .y).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Specificity of
:not(),:is()and:has()is the specificity of the most specific selector in their argument, computed once for the rule, not per matched element. That's why:is(#id, .class)acts as an ID even for elements matched by.class. - ▸
:has()is the first selector whose result depends on later and deeper elements, so the browser must re-check ancestors and earlier siblings when content changes. Engines track which elements could be affected; an anchored argument (:has(> img),:has(+ p)) limits that work. - ▸
:has()can't contain pseudo-elements or another:has(). Test support with@supports selector(:has(a))if you must serve very old browsers, and keep the page usable without it. - ▸
Forgiving lists make progressive enhancement safer:
:is(:focus-visible, .js-focus)keeps working in an older browser that doesn't know:focus-visible, while the same pair in a plain list would drop the whole rule there. Pseudo-elements are not allowed inside:is(),:where()or:has().
Remember this
- 1
A functional pseudo-class is a pseudo-class with brackets that takes other selectors as its argument, like
:not(.active). The argument is a selector list: one or more selectors separated by commas. - 2
:not(X)matches elements that do not match X.li:not(:last-child)is every item except the last,input:not([type="checkbox"], [type="radio"])is every input except those two types. Careful::not(.a)on its own (no type in front) means "any element without class a", which includeshtmlandbody. - 3
:is(X, Y, Z)matches an element that matches any of the selectors in the list. It shortens repetitive selectors::is(article, aside, section) h2replaces three separate selectors. Its list is forgiving: an invalid or unsupported selector inside it is ignored instead of killing the whole rule. - 4
:where(X, Y)matches exactly like:is(), with one difference: its specificity is always zero. That makes it ideal for defaults (resets, library styles) that any later class should be able to override without a fight. - 5
Specificity (the priority score from Topic 1.1) of these four:
:is(),:not()and:has()take the specificity of their most specific argument, so:is(#nav, .menu) acounts as an ID selector even when the element matched through.menu.:where()always counts zero. Phase 2 shows the full scoring. - 6
:has(X)matches an element if X, read relative to it, finds something..card:has(img)is a card that contains an image;.card:has(> img)one whose direct child is an image;h2:has(+ p)anh2followed immediately by a paragraph;form:has(input:invalid)a form with an invalid field. For the first time CSS can style a parent or an earlier sibling based on what comes inside or after it. It is Baseline 2023, supported in every current browser. Unlike:is(), its list is not forgiving, and:has()cannot be nested inside another:has().
Explain it without notes
Compare :is() and :where(): how do they match, and how does their specificity differ?
What is the specificity of .btn:not(#primary)? Why can that cause trouble?
What problem does :has() solve, and give three practical examples.
What is a forgiving selector list, and which of these four functions has one?
Practice
Give every button except those with class ghost a solid blue background, in one rule.
Write a reset that removes list bullets and padding from any ul or ol that has a class, with zero specificity so any later rule can add them back.
Highlight a .field wrapper with a red left border when the input inside it is invalid.
Trade-offs
- ↔
:is()shortens CSS but silently raises specificity to its strongest argument;:where()keeps specificity flat but can be overridden by accident. - ↔
:has()removes a lot of class-toggling JavaScript, but its styles depend on page structure; very broad:has()rules on large, frequently changing pages can cost style recalculation time.
Done when you can
I can exclude elements with
:not(), including lists.I can shorten selectors with
:is()and know it is forgiving.I can write zero-specificity defaults with
:where().I can state the specificity of
:not(),:is(),:where()and:has().I can select parents and earlier siblings with
:has().