Simplifying Selectors and Mastering Specificity
Long selector lists that share the same declarations are a maintenance problem in every sizable stylesheet. CSS :is() and :where() compress such lists into a single rule, with precise control over specificity, and with an error tolerance behavior that classic comma lists simply do not have.
Table of Contents
- 1. The problem with long selector lists
- 2. CSS :is(): syntax and basic behavior
- 3. CSS :where(): setting specificity to zero
- 4. Specificity in detail: :is() vs. :where() vs. direct
- 5. Error tolerance and invalid selectors
- 6. Combining with :not() and :has()
- 7. :is() in nested selectors
- 8. :is(), :where(), and :matches() compared
- 9. In practice: design systems and reset stylesheets
- 10. Summary
- 11. FAQ
1. The problem with long selector lists
In every mid-sized stylesheet you find rules like h1, h2, h3, h4, h5, h6 { margin-top: 1.5rem; } or article p, section p, aside p, main p { line-height: 1.75; }. That works fine, but it is fragile: whenever a new element needs to join the list, every such rule has to be found and extended. And if a single selector in the list is invalid, the browser drops the entire rule in a classic comma list, including all the other valid selectors in that same list. That is the error handling model for classic CSS selector lists.
CSS :is() and CSS :where() are the modern answer to this maintenance problem. Both functions take a selector list as an argument and match any element that matches one of the included selectors. The crucial difference from a normal comma list lies in three areas: specificity calculation, error tolerance for invalid selectors, and combinability with other pseudo-classes. These three properties make CSS :is() and CSS :where() a fundamental tool of modern CSS architecture.
The concept was originally introduced as :matches() in CSS Selectors Level 4 and implemented early by Safari. The final names :is() and :where() were standardized once it became clear that two variants with different specificity behavior were needed: one that inherits the specificity of its arguments, and one that always has specificity zero.
2. CSS :is(): syntax and basic behavior
CSS :is() accepts a comma separated selector list and matches any element that matches at least one of the given selectors. The syntax :is(h1, h2, h3) is functionally equivalent to h1, h2, h3 in a comma list, with the key difference that the specificity of :is() equals that of the most specific argument in the list. That means :is(#id, .class, h1) has the specificity of an ID, because #id is the most specific selector in the list.
The most important use case for CSS :is() is simplifying selectors that combine the same ancestor in different contexts. Instead of nav a, header a, footer a you write :is(nav, header, footer) a. Instead of article h1, article h2, article h3 you write article :is(h1, h2, h3). That is not only shorter but also semantically clearer: the intent of the selector is immediately readable, without mentally reconstructing the shared structure of several selectors.
/* CSS :is(): reducing repetitive selector lists */
/* Before :is(): verbose repetition */
article h1,
article h2,
article h3,
article h4 {
font-weight: 700;
color: #1e1b4b;
}
/* After :is(): single readable rule */
article :is(h1, h2, h3, h4) {
font-weight: 700;
color: #1e1b4b;
}
/* Combining ancestors with :is() */
:is(nav, header, .site-menu) a {
color: #4a1d96;
text-decoration: none;
}
:is(nav, header, .site-menu) a:hover {
color: #7c3aed;
text-decoration: underline;
}
/* Nested headings in any content area */
:is(article, section, .content-block) :is(h2, h3) {
margin-top: 2rem;
margin-bottom: 0.75rem;
line-height: 1.25;
}
3. CSS :where(): setting specificity to zero
CSS :where() behaves identically to CSS :is() when it comes to matching: it matches the same element given the same arguments. The one, decisive difference is that the specificity of :where() is always zero, regardless of how specific the selectors in its argument are. :where(#id, .class, h1) a has the specificity of a alone, because the :where() part contributes nothing to specificity. That makes CSS :where() the ideal tool for reset stylesheets, base styles, and design system foundations.
The benefit becomes obvious once you think about the cascade: a rule in a reset stylesheet should be overridable without developers needing high specificity in their component styles. If the reset uses :where(h1, h2, h3) { margin: 0; }, that rule has specificity zero and can be overridden by any class rule without risking specificity wars. This is the design principle behind modern CSS resets such as Andy Bell's, where every base style uses CSS :where() so consumers keep full control.
A concrete example: Tailwind CSS and other utility-first frameworks regularly run into specificity problems when global base styles and utility classes collide. If all base styles were set with CSS :where(), utility classes would always win, since even a plain class rule has higher specificity than zero. That resolves one of the most common conflicts in such projects without any extra workarounds.
4. Specificity in detail: :is() vs. :where() vs. direct
The specificity model of CSS :is() follows the rule of the most specific argument: the specificity of the entire :is() function equals the highest specificity among all selectors in the argument list. That has an important consequence: if you write :is(.card, .article, #featured), the whole selector carries ID specificity, even if the element is only matched through .card and not through #featured. That is intuitive for experienced CSS authors but can be surprising for others.
CSS :where() always has specificity zero. That is not a limitation but a feature: it makes styles explicitly overridable and encourages clean cascade hierarchies. :where() is well suited for anything meant as a base or default that should never block an override. CSS :is() is well suited for selectively applied styles where the specificity of the arguments is semantically relevant.
/* Specificity comparison: :is(), :where(), and direct selectors */
/* Specificity (0,1,0): .card contributes 1 class */
.card a { color: blue; }
/* Specificity (0,1,0): :is() takes the max of (.card) = 1 class */
:is(.card) a { color: blue; }
/* Specificity (0,0,0): :where() always contributes 0 */
:where(.card) a { color: blue; }
/* Danger: mixed specificity in :is() list */
/* Entire rule has ID specificity (1,0,0) even if matched by .card */
:is(#hero, .card, .article) h2 {
font-size: 2rem; /* overrides all class-level h2 rules */
}
/* Safe: :where() for base styles, always overridable */
:where(article, section, .prose) p {
line-height: 1.75;
margin-bottom: 1rem;
}
/* Override with any class, wins because :where() is specificity 0 */
.compact p {
line-height: 1.4;
margin-bottom: 0.5rem;
}
5. Error tolerance and invalid selectors
Error tolerance is one of the most often overlooked differences between CSS :is() and classic comma lists. In a normal selector list such as ::-webkit-input-placeholder, ::placeholder { color: gray; }, every browser drops the entire rule if it does not recognize even one selector. Firefox does not know ::-webkit-input-placeholder and as a result also ignores the perfectly valid ::placeholder rule. That is the well known problem with vendor prefixed selectors in comma lists.
CSS :is() and CSS :where() use an error tolerant parsing strategy: invalid selectors in the argument list are ignored, but the entire rule stays valid. That means you can safely mix future or experimental selectors with established ones: :is(.supports-new-feature, :experimental-pseudo) .element still works correctly for .supports-new-feature .element in browsers that do not recognize :experimental-pseudo. This behavior makes gradual CSS migrations significantly safer.
The boundary of that tolerance lies in the concept of forgiving selectors: the specification describes the argument lists of CSS :is() and CSS :where() as "forgiving selector lists". The opposite, "unforgiving", applied to arguments of :not() in earlier implementations. Modern :not() also accepts selector lists, but behaves differently across browser versions when given invalid arguments. That is an important distinction to keep in mind when combining it with :not().
6. Combining with :not() and :has()
Combining CSS :is() with :not() is a powerful tool for precise selectors. :is(h1, h2, h3):not(.no-margin) matches all three heading types except those carrying the .no-margin class. That is more elegant than the alternative, h1:not(.no-margin), h2:not(.no-margin), h3:not(.no-margin). The reverse is useful too: :not(:is(h1, h2, h3)) matches everything except headings.
Combining it with :has() opens up even more powerful patterns. :is(article, section):has(:is(h2, h3)) matches every article and section that contains an h2 or h3. That is a purely CSS based "parent selector" that used to require JavaScript. Together, CSS :is(), :where(), :not(), and :has() form a selector language that can express structural relationships in the document directly, without adding HTML classes.
/* CSS :is() with :not() and :has() combinations */
/* All headings except those with .decorative class */
:is(h1, h2, h3, h4):not(.decorative) {
font-family: inherit;
font-weight: 700;
}
/* Inverse: style everything that is NOT a heading */
:not(:is(h1, h2, h3, h4, h5, h6)) {
max-width: 70ch;
}
/* :has() + :is(): sections containing any heading level */
:is(article, section):has(:is(h2, h3)) {
padding-top: 2rem;
border-top: 1px solid #e2e8f0;
}
/* Links inside content areas, not inside nav or footer */
:is(article, main, .prose) a:not(:is(nav a, footer a, .btn)) {
color: #4a1d96;
text-decoration: underline;
text-underline-offset: 2px;
}
/* Form inputs: all text-like inputs in one rule */
:is(input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
textarea) {
border: 1px solid #c4b5fd;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
}
7. :is() in nested selectors
With native CSS nesting (&), now supported in all modern browsers, CSS :is() plays a new role. Inside a nested block, selectors can be compressed with :is() without giving up the benefit of the nesting structure. .card { & :is(h2, h3) { color: purple; } } is functionally equivalent to .card :is(h2, h3) { color: purple; }, but organized more cleanly within the component rule.
One important detail when nesting: combining :is() with the implicit & gives you a compact notation for state variants. .button { &:is(:hover, :focus-visible) { background: #7c3aed; } } bundles hover and focus styles into a single rule. That reads better than two separate nested blocks and makes it clear that both states are meant to receive the same styling, a semantically clear way to express interactive states.
8. :is(), :where(), and :matches() compared
:matches() was the earlier name for :is() in the CSS specification and in Safari. It is functionally identical and has been replaced by :is(). Legacy code occasionally still contains :matches(), which was the only available name in Safari 9 to 13.
| Function | Specificity | Error Tolerance | Typical Use |
|---|---|---|---|
:is() |
Max of the arguments | Yes (forgiving) | Components, rules with known specificity |
:where() |
Always 0 | Yes (forgiving) | Resets, base styles, always overridable |
| Comma list | Individual per selector | No (unforgiving) | Simple lists without invalid selectors |
:matches() |
Max of the arguments | Yes | Legacy alias for :is(), Safari 9 to 13 only |
:not() (modern) |
Max of the arguments | Partial (browser dependent) | Exclusions, negations |
The most important practical difference between CSS :is() and comma lists shows up with vendor prefixed selectors and forward-looking selectors. In classic comma lists you must repeat the same rule for every prefix in a separate rule, because one invalid declaration invalidates the whole rule. With CSS :is() you can group all the variants into a single argument and only need to write the declarations once.
9. In practice: design systems and reset stylesheets
In modern design systems, CSS :is() and CSS :where() have become indispensable tools. The base typography of a design system should be set with CSS :where() so that consumers do not run into specificity problems when defining variants. Component specific styles meant to apply in a particular context are combined with CSS :is() to express that context clearly.
A concrete example from a Magento Hyva theme: the global stylesheet sets typography with :where(h1, h2, h3, h4, h5, h6), so that any Tailwind class like text-2xl can override that base without specificity problems. Component specific link styles inside content blocks are defined with :is(.cms-content, .blog-post, .product-description) a, which gives them higher specificity than general link resets but lower specificity than explicit modifier classes.
/* CSS :is() and :where() in design system architecture */
/* Base layer: always overridable, specificity 0 */
@layer base {
:where(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-heading);
line-height: 1.25;
font-weight: 700;
}
:where(p, li, dd) {
line-height: 1.75;
max-width: 70ch;
}
:where(a) {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
}
/* Component layer: :is(), takes specificity of arguments */
@layer components {
:is(.card, .panel, .widget) :is(h2, h3) {
color: #1e1b4b;
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
:is(.prose, .cms-content) a:not(.btn):not([class*="text-"]) {
color: #4a1d96;
text-decoration: underline;
}
}
Mironsoft
CSS architecture, Hyva themes, and maintainable stylesheet systems
Want CSS selectors that are maintainable and specificity safe?
We design CSS architectures with clear cascade layers, :where() for overridable bases, and :is() for precise component rules, without specificity wars.
CSS Audit
Analyzing specificity problems, redundant selector lists, and cascade conflicts
Selector Refactoring
Replacing long comma lists with :is() and :where() and building a layer architecture
Hyva Integration
Structuring Tailwind and custom CSS in Magento themes without conflicts
10. Summary
CSS :is() and CSS :where() are two of the most productivity boosting pseudo-class functions from CSS Selectors Level 4. CSS :is() compresses selector lists at the specificity of the most specific argument, ideal for component styles where the context should carry specificity. CSS :where() does the same thing at specificity zero, ideal for reset stylesheets, base layers, and any style that should always remain overridable in the cascade.
The error tolerant behavior of both functions toward invalid selectors in the argument list makes them safer than classic comma lists when working with vendor prefixes or forward-looking selectors. Combined with :not(), :has(), and native CSS nesting, you get selectors that express structural relationships in the document precisely, without misusing HTML classes. The basic rule of thumb: use CSS :where() for anything that forms a base, and CSS :is() for anything meant to override deliberately or be specific.
CSS :is() and :where(): The essentials at a glance
:is() specificity
Inherits the specificity of the most specific argument. :is(#id, .class) has ID specificity, even if the element is only matched through .class.
:where() specificity
Always zero. Ideal for reset stylesheets and base layers: any class selector rule overrides :where() styles without specificity problems.
Error tolerance
Invalid selectors in the :is()/:where() list are ignored, the rule stays valid. Classic comma lists are dropped entirely if one selector is invalid.
:not() combination
:is(h1,h2,h3):not(.no-style) bundles exclusions compactly. :not(:is(...)) excludes an entire group, a pattern that used to be very verbose.