
:is(), :where(), and :not(): Simplifying Complex Selectors
If you've maintained a stylesheet for more than a few months, you've probably seen selectors like this one:
.article h1 a:hover,
.article h2 a:hover,
.article h3 a:hover,
.sidebar h1 a:hover,
.sidebar h2 a:hover,
.sidebar h3 a:hover {
color: #2563eb;
}
Six selectors, one idea. It's hard to read, easy to get wrong, and painful to extend. Modern CSS gives you three functional pseudo-classes that fix this kind of sprawl: :is(), :where(), and :not(). With them, that whole block becomes a single line:
:is(.article, .sidebar) :is(h1, h2, h3) a:hover {
color: #2563eb;
}
But these functions aren't only about saving keystrokes. They also give you precise control over specificity, which is one of the most common sources of CSS frustration. In this guide, I'll walk through how each one works, how their specificity differs, and where they really shine in day-to-day code.
The Problem These Selectors Solve
Complex selectors cause three kinds of pain:
- Repetition. Every combination of parents and children has to be spelled out.
- Fragility. Traditional selector lists are unforgiving. If one selector in a comma-separated list is invalid in a browser, the entire rule is discarded.
- Specificity creep. Each added class or ID raises the selector's weight, so overriding it later means writing an even heavier selector. Over time, you end up in a specificity arms race.
:is() tackles repetition. :where() tackles repetition and specificity creep. :not() makes exclusions readable. And both :is() and :where() use a forgiving selector list, which helps with fragility.
:is(): Match Any of These
:is() takes a list of selectors and matches any element that matches at least one of them. Think of it as "this OR that OR that" at a single position in your selector.
:is(header, main, footer) a {
color: #0f172a;
}
This matches links inside a header, a main, or a footer. It's identical in effect to writing three separate selectors.
Where :is() Can Go
You can place :is() anywhere in a compound or complex selector:
/* At the start */
:is(.card, .panel) .title {
font-weight: 700;
}
/* In the middle */
.nav :is(ul, ol) > li {
display: inline-block;
}
/* At the end, combined with a pseudo-class */
.button:is(:hover, :focus-visible) {
background: #4f46e5;
}
That last example is one of my favorite uses. Hover and focus-visible states usually share styles, and :is() groups them neatly on one element.
How :is() Calculates Specificity
This is the part people often miss. :is() takes the specificity of its most specific argument. It doesn't matter which argument actually matched.
:is(#hero, .banner) h2 {
color: #f8fafc;
}
The specificity here is 1, 0, 1, because #hero is the heaviest thing in the list. Even an h2 inside a plain .banner gets ID-level specificity from this rule. So if you later write .banner h2 { color: #111827; } (specificity 0, 1, 1), it will lose, and you'll wonder why.
The takeaway: don't mix IDs with classes inside :is() unless you really mean it. Keep arguments at a similar weight.
:where(): Same Matching, Zero Specificity
:where() matches exactly the same elements that :is() would. The one difference is that its specificity is always zero, no matter what you put inside.
:where(.article, .sidebar) :where(h1, h2, h3) {
margin-block: 1.5em 0.5em;
}
The specificity of that entire selector is 0, 0, 0. Any other rule targeting these headings, even a bare h2, will override it, as long as it comes later or has any specificity at all.
Why Zero Specificity Is Useful
Zero specificity sounds like a weakness, but it's exactly what you want for defaults: base styles that should apply everywhere but give way instantly whenever a component needs something different.
A classic example is a CSS reset:
:where(ul, ol)[role="list"] {
list-style: none;
padding: 0;
margin: 0;
}
:where(img, video, svg) {
display: block;
max-width: 100%;
}
:where(button, input, select, textarea) {
font: inherit;
}
Because these rules have zero specificity, your component styles never have to fight them. A simple .avatar { display: inline-block; } wins without any extra effort.
Library and Design System Styles
If you ship CSS that other people consume, such as a component library, a WordPress theme, or a shared design system, wrapping your default selectors in :where() is a kindness. It lets consumers override your styles with plain class selectors instead of !important or bloated chains.
/* In the library */
:where(.ui-button) {
padding: 0.625rem 1rem;
border-radius: 8px;
background: #6366f1;
color: #fff;
}
:where(.ui-button):where(:hover, :focus-visible) {
background: #4f46e5;
}
/* In the consuming app: wins easily */
.checkout .ui-button {
background: #16a34a;
}
:where() vs. Cascade Layers
Cascade layers (@layer) solve a similar problem at a larger scale: they let you rank entire groups of styles regardless of specificity. They work well together. Use @layer to order big buckets such as reset, base, components, and utilities. Use :where() for fine-grained control inside a single rule.
:not(): Match Everything Except
:not() matches elements that do not match the selectors inside it.
/* Every input except checkboxes and radios */
input:not([type="checkbox"], [type="radio"]) {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
}
Older CSS only allowed a single simple selector inside :not(), so you had to chain :not(.a):not(.b). Current browsers accept a full selector list, including complex selectors with combinators:
/* Links that are not buttons and not inside the main nav */
a:not(.button, .site-nav a) {
text-decoration: underline;
}
Specificity of :not()
Like :is(), :not() takes the specificity of its most specific argument. The :not itself adds nothing.
.card:not(.is-featured) {
} /* 0, 2, 0 */
.card:not(#promo) {
} /* 1, 1, 0 */
This catches people out. Writing :not(#promo) to exclude one element quietly gives the rule ID-level weight.
Common :not() Patterns
/* Spacing between items, but not after the last one */
.tag-list li:not(:last-child) {
margin-right: 0.5rem;
}
/* Placeholder anchors without an href should not look clickable */
a:not([href]) {
color: inherit;
cursor: default;
}
/* Everything in a form row except the label */
.form-row > :not(label) {
flex: 1;
}
/* Hide all slides except the active one */
.slide:not(.is-active) {
display: none;
}
A Careful Note About Descendants and :not()
:not() applies to the element it's attached to, not to its ancestors. This selector does not mean "paragraphs that aren't inside .note":
:not(.note) p {
color: #334155;
}
It means "a p that has some ancestor that isn't .note". Since html and body aren't .note, it matches virtually every paragraph. To exclude paragraphs inside .note, put the whole condition on the paragraph instead:
p:not(.note p) {
color: #334155;
}
This works in current browsers because :not() accepts complex selectors.
Forgiving vs. Unforgiving Selector Lists
Here's a subtle but valuable difference. Normal selector lists are unforgiving: if any selector is invalid, the whole rule is dropped.
/* In a browser that doesn't recognize :fake-state, NOTHING here applies */
.button:hover,
.button:fake-state {
background: #4f46e5;
}
:is() and :where() accept a forgiving selector list. Invalid selectors inside them are ignored, and the valid ones still work:
/* :hover still applies even if :fake-state is unknown */
.button:is(:hover, :fake-state) {
background: #4f46e5;
}
That makes :is() a useful tool when experimenting with newer pseudo-classes alongside well-supported ones.
:not(), on the other hand, is not forgiving. An invalid selector inside :not() makes the whole selector invalid. That's deliberate: if :not() silently dropped an unknown argument, it could end up matching far more than you intended.
Real-World Refactors
Let's put all three together on some realistic code.
Before: A Prose Component
.prose h1,
.prose h2,
.prose h3,
.prose h4 {
color: #0f172a;
line-height: 1.25;
}
.prose p,
.prose ul,
.prose ol,
.prose blockquote {
margin-bottom: 1.25em;
}
.prose a:hover,
.prose a:focus {
color: #1d4ed8;
}
.prose ul li,
.prose ol li {
margin-bottom: 0.25em;
}
After
.prose :where(h1, h2, h3, h4) {
color: #0f172a;
line-height: 1.25;
}
.prose :where(p, ul, ol, blockquote) {
margin-bottom: 1.25em;
}
.prose a:is(:hover, :focus-visible) {
color: #1d4ed8;
}
.prose :where(ul, ol) > li {
margin-bottom: 0.25em;
}
Notice the choices. I used :where() for the element lists so they keep the low specificity of just .prose (0, 1, 0). Authors writing .prose .callout p can override them easily. For the hover state, I used :is() because pseudo-class weight is fine there, and I also swapped :focus for :focus-visible.
Before: Form Controls
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
select,
textarea {
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
After Form Controls
:where(
input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]),
select,
textarea
) {
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
Instead of listing every text-like type, this excludes the few that shouldn't get a text-field look. Any new input type, such as tel or url, automatically gets the styles. And thanks to :where(), the rule stays at zero specificity, so a component class can easily restyle a particular field.
Combining with :has()
These three functions pair naturally with :has():
/* A card that has an image, but isn't the compact variant */
.card:has(img):not(.card--compact) {
display: grid;
grid-template-columns: 200px 1fr;
}
/* Any field group containing a required, empty, or invalid input */
.field:has(:is(input, textarea):user-invalid) {
border-color: #f472b6;
}
Best Practices
- Use
:where()for defaults and resets, so they never fight component styles. - Use
:is()when you want normal specificity, for example grouping interaction states like:hoverand:focus-visible. - Keep arguments at similar weights inside
:is()and:not(). One ID in the list raises the specificity for every match. - Put
:not()on the element you're excluding, not on an ancestor, unless you really mean "has any ancestor that isn't". - Don't over-nest.
:is(:is(.a, .b) :where(.c, .d))is valid but unreadable. Clarity beats cleverness. - Check specificity in DevTools. Chromium and Firefox show a selector's specificity when you hover over it in the Styles pane.
Browser Support
:is(), :where(), and the selector-list form of :not() are supported in all current major browsers: Chrome, Edge, Firefox, and Safari, and have been for several years. If you still need to support very old browsers, the older single-argument :not(.class) form works almost everywhere, and you can fall back to longhand selector lists instead of :is() and :where().
Conclusion
:is(), :where(), and :not() are small additions with an outsized impact. :is() collapses repetitive selector lists into readable one-liners. :where() does the same while stripping specificity to zero, which makes it the perfect tool for resets and shareable defaults. And :not() turns exclusions into plain, readable intent.
Used together and deliberately, they help you write stylesheets that are shorter, easier to override, and much less likely to fall into the specificity trap.


