CSS :is() and :where() Pseudo-Classes
Introduction
The CSS :is() and :where() pseudo-classes group selector alternatives inside one functional expression. They can replace repeated selector prefixes, make component rules easier to scan, and combine types, classes, attributes, or states without copying the same declaration block. Their matching behavior is nearly identical: an element satisfies the function when it matches at least one selector in the argument list.
The major difference is specificity. Is adopts the specificity of the strongest argument in its list. Where and everything inside it contribute zero specificity. That contrast makes is useful when normal selector weight should be preserved and where useful for defaults that must remain easy to override. Choosing between them is therefore a cascade decision, not merely a preference for shorter syntax.
What Is :is()?
The :is() pseudo-class matches an element if the element satisfies any selector supplied in its argument list. The expression :is(h1, h2, h3) matches heading elements of levels one through three. When it follows a context, such as article :is(h1, h2, h3), it selects those headings only when they are descendants of article.
Is does not create a new group in the DOM or apply styles to the list itself. It is part of a selector that identifies existing elements. Browser matching updates normally when classes, attributes, states, or structure change.
What Is :where()?
The :where() pseudo-class uses the same alternative-list matching model. :where(h1, h2, h3) matches the same headings as is. Its defining feature is that the complete where expression has zero specificity, regardless of whether its arguments contain classes, IDs, attributes, states, or complex selectors.
Selectors outside where still count. In .article :where(h2, h3), the article class contributes 0-1-0, while the heading choices inside where add nothing. This lets authors scope a rule accurately without making every internal detail harder to override.
Basic Syntax
:is(selector-one, selector-two, selector-three) {
property: value;
}
:where(selector-one, selector-two, selector-three) {
property: value;
}
The arguments form a selector list separated by commas. The function is normally combined with other selector components or relationships. Whitespace, child, adjacent sibling, and general sibling combinators can appear around the function, while supported complex selectors can appear inside the list. Each complete selector must remain valid according to the function's grammar.
A Basic :is() Example
article :is(h2, h3, h4) {
color: #123f63;
line-height: 1.25;
}
This single rule replaces three repeated selectors: article h2, article h3, and article h4. The declarations apply to the headings, not article. The shared article prefix appears once, reducing typing and the chance that one selector is omitted during maintenance.
Grouping is most helpful when alternatives share the same relationship and declarations. If each heading level needs different design, separate rules remain clearer.
A Basic :where() Example
.prose :where(h2, h3, h4) {
margin-block: 1.5em 0.65em;
}
The rule matches headings inside prose, just as the is version would. Its specificity is only that of .prose because the heading alternatives inside where contribute zero. A direct utility or component class on a heading can therefore override the margin without reproducing the prose context.
This makes where especially useful for content defaults, resets, theme foundations, and library styles intended to yield to local component decisions.
Why These Functions Reduce Repetition
Without grouping, a selector that combines three container types with three heading types can require nine comma-separated selectors. :is(article, section, aside) :is(h2, h3, h4) expresses all nine combinations compactly. When a new container or heading is added, one list changes instead of several repeated branches.
Shorter is not automatically better. If grouped alternatives have unrelated meanings or future developers must mentally expand a large Cartesian product, separate named component selectors may be clearer. Use grouping where the alternatives genuinely share a styling rule.
How Browser Matching Works
Conceptually, the browser tests whether a candidate element satisfies at least one argument selector. If any argument matches, the functional pseudo-class matches. Surrounding selector components and combinators must also match. For .panel :is(a, button).action, the target must be an anchor or button, carry the action class, and be a descendant of panel.
Browser engines compile and optimize selectors, so they do not necessarily perform a literal left-to-right loop. The logical OR model is still the correct way to understand the result.
Forgiving Selector Lists
Both is and where use forgiving selector-list parsing. If one argument is invalid or unsupported, the browser can discard that argument while retaining valid alternatives. In an ordinary comma-separated selector list, one invalid selector may invalidate the complete rule. Forgiving parsing supports progressive enhancement inside these functions.
If every argument is invalid, the function matches nothing. Forgiving behavior should not become an excuse to ship spelling errors. Linters and tests are still necessary, because a silently discarded branch may remove styling from one component while the rest of the rule appears functional.
Ordinary Selector Lists Versus :is()
An ordinary list such as h1, h2, h3 calculates specificity separately for whichever selector matched. Is behaves differently: it adopts the specificity of the most specific selector in its entire argument list. This distinction matters even when both forms select the same elements.
Ordinary lists remain simple for top-level alternatives. Is becomes more valuable when alternatives appear in the middle of a longer selector, where repeating prefixes and suffixes would otherwise create many branches. Choose based on structure and cascade behavior, not line count alone.
Specificity of :is()
The is token itself does not add an ordinary pseudo-class count. Instead, its specificity is replaced by the specificity of the strongest selector in its argument list. :is(h1, .title) has class-level specificity because .title is stronger than h1. Adding an ID branch raises the complete function to ID-level specificity.
Selectors outside the function are added normally. article :is(h2, .heading) combines the article type with the strongest class argument, producing 0-1-1. Always inspect the strongest list member, not only the branch that appears to match a particular element.
The ID Specificity Trap
:is(.card, #featured) has ID-level specificity because #featured is the strongest argument. A card matched through the class branch receives a rule with that same ID-level weight. This can surprise developers who later attempt to override it with another class.
Avoid mixing radically different specificity levels in one is list unless the strong weight is intentional. Split the rule, replace the ID with a class, or use where when the alternatives should not affect cascade strength.
Specificity of :where()
Where contributes 0-0-0 no matter what its list contains. :where(#app .content h2) matches a highly specific context but adds no specificity. A later h2 rule can override it under otherwise equal cascade conditions because the h2 carries type specificity while the where rule carries none.
Zero specificity applies only to the where expression. In .theme :where(#app h2), the theme class outside still contributes. Authors can therefore decide which context should count and which should be specificity-neutral.
Matching Equivalence, Cascade Difference
:is(h2, .section-title) {
color: navy;
}
:where(h2, .section-title) {
margin-block-end: 0.75rem;
}
The functions match the same set of elements in this example. The is color rule carries class-level specificity because .section-title is the strongest argument. The where margin rule has zero specificity. A later type selector can easily replace the margin but may not replace the color without equal or stronger cascade weight.
This demonstrates why choosing between them belongs in architecture discussions. Matching alone does not reveal the full behavior.
Grouping Classes
A component can use .button:is(.primary, .secondary) to select button elements carrying either variant class. The leading button class and strongest argument class both contribute specificity, producing two class-category counts. If every button variant should share a declaration, the base .button selector may be simpler.
Grouping is useful when only a subset shares a feature, such as elevated primary and danger buttons. Class names should still describe meaningful roles; is is not a substitute for a coherent component model.
Grouping Element Types
:is(input, select, textarea) creates a concise group of common form controls. A form context can add ownership: .profile-form :is(input, select, textarea). This does not automatically include buttons, output, custom elements, or contenteditable regions, so define the intended group explicitly.
Native controls have different behaviors and platform styling. Shared typography or sizing is reasonable, but broad appearance resets require cross-browser, keyboard, forced-colors, zoom, and touch testing.
Grouping Attribute Selectors
Attribute alternatives can share a rule: input:is([type="email"], [type="url"], [type="tel"]). The input type provides a positive base, and the list identifies related text-entry modes. Each attribute selector has class-category specificity, so is adopts that category.
Literal attributes and behavioral defaults can differ. An input with no type attribute behaves as text but does not match [type="text"]. Use a shared class when a design category cannot be represented reliably by attribute presence alone.
Grouping Interaction States
.button:is(:hover, :focus-visible) applies declarations while either state is true. This can provide equivalent emphasis for pointer and keyboard interaction. The selector still needs a separate clear focus indicator if the shared declaration is only a color or shadow that may not identify keyboard location sufficiently.
Active, disabled, checked, and validation states can also appear in lists. Avoid grouping states merely because they look similar in one mockup; their meanings and accessibility requirements remain different.
Grouping Structural States
.item:is(:first-child, :last-child) selects boundary items. In a single-item list, the same element satisfies both but receives the declaration once. Structural matching follows DOM order, including hidden elements, and not visual order changed by flex or grid.
If first and last need distinct corners or borders, separate rules communicate those differences. Group only declarations that are truly identical at both boundaries.
Using :where() for Resets
Reset and normalization styles should establish a foundation without overpowering components. A rule such as :where(ul, ol)[class] can remove default list spacing for classed lists with deliberately low specificity. Component classes can then restore or redefine presentation easily.
Resets must preserve useful semantics and accessibility. Removing list markers everywhere can make lists harder to recognize, especially when layout does not otherwise communicate grouping. Scope resets to design-system contexts and test real content.
Using :where() for Content Defaults
Rich-text regions often need consistent heading, paragraph, list, code, and blockquote defaults. .prose :where(h2, h3, p, ul, ol, blockquote) keeps the prose context while avoiding extra weight from each element alternative. A special embedded component can override those defaults with one class.
This pattern is more maintainable than a highly specific descendant chain. It also creates an intentional contract: prose styles are defaults, while named components own exceptions.
Using :where() in Themes
A theme wrapper can supply low-specificity defaults through .theme-dark :where(a, button, input). The theme class still contributes specificity; the varied internal element choices do not. Component classes can override details without reproducing every ancestor and element selector.
Custom properties are often even better for theme values because components consume tokens while retaining their own selectors. Where can support structural defaults, and custom properties can carry color, spacing, and typography decisions.
:where() and Component APIs
A component library can use where around optional context selectors so consumer classes remain effective. For example, :where(.toolbar, .dialog-actions) .button gives the target button one class-category count while toolbar and dialog-actions add none. The library can scope layout without making context expensive to override.
Document which selectors are stable public hooks. Low specificity does not make private DOM structure a reliable API. Consumers should customize through exposed classes, parts, attributes, or custom properties rather than depending on incidental wrappers.
Combining :is() and :where()
The functions can appear in one selector when different groups need different cascade behavior. :where(.article, .lesson) :is(h2, .section-title) gives zero weight to the container alternatives but class-level weight to the strongest target alternative. This allows flexible scoping while preserving meaningful target weight.
Use mixed functions sparingly and explain the intent in architecture documentation. If a selector requires repeated specificity calculations to understand routine overrides, simpler component classes may be preferable.
Using Them with :not()
Where is particularly useful inside not when exclusions should not raise specificity. .button:not(:where(.primary, .danger)) matches buttons that are neither primary nor danger, while the excluded classes contribute zero. The selector keeps only the base button class weight.
Using is inside not preserves the strongest argument weight instead. Choose based on cascade goals. The matching logic may be identical, but the resulting selector strength differs.
Using Them with :has()
A relational condition can group descendants with is: .card:has(:is(img, video)) matches cards containing either type of media. Where can neutralize the media alternatives' weight: .card:has(:where(img, video)). The card class and has calculation still follow the relevant specificity rules.
These selectors can remove helper classes, but they also encode structural logic. Use them when the relationship is stable and supported, and avoid hiding content-quality problems such as missing headings or labels.
Complex Selectors in the Argument List
Modern is and where can accept complex selectors such as descendant or child relationships within their lists. A selector like :is(article h2, aside h3) matches h2 elements inside articles and h3 elements inside asides. The target is determined by the final subject of each complex selector.
Complex arguments can become difficult to expand mentally. When branches have unrelated structures, a normal comma-separated list may be easier to review. Grouping should reveal commonality, not conceal it.
Relative Selectors and :has()
Is and where are not general replacements for has. A leading combinator such as > img is a relative selector and belongs in contexts whose grammar accepts relative selectors, notably has. To select a card with a direct image child, write .card:has(> img), not an is expression that attempts to look forward from the card.
Use is and where to group alternatives for the element being matched; use has when the subject depends on related descendants or siblings. Keeping those roles separate avoids invalid selectors.
Pseudo-Elements and Functional Grouping
Pseudo-elements generally cannot be placed arbitrarily inside is or where argument lists. A tempting selector such as :is(.note, .warning)::before is the clearer pattern: group the originating elements, then append the pseudo-element to the complete function. This creates generated content for both note and warning elements.
Generated content should remain decorative or redundant with accessible text. Do not place essential labels, instructions, or status solely in a pseudo-element.
CSS Nesting and :is()
Native CSS nesting often has specificity behavior comparable to an is-style selector list: the strongest parent-list selector can influence nested rules. Grouping parent selectors with widely different weights can therefore raise every nested result. Inspect the equivalent expanded selector rather than assuming indentation has no cascade effect.
Is can sometimes make an expanded relationship more explicit than deeply nested syntax. Choose the form that team tooling and browser targets support, and inspect generated CSS when preprocessors transform nesting.
Cascade Layers and Zero Specificity
Where does not override higher cascade dimensions. A zero-specificity rule in a higher-priority layer can defeat a high-specificity rule in a lower layer because layer order is considered before specificity. Conversely, a where rule in a lower-priority layer remains a default even if it appears later.
Layers and where work well together: layers establish broad priority categories, while where controls weight inside a category. Design both deliberately rather than expecting zero specificity alone to solve every conflict.
Source Order Still Matters
When competing declarations tie on origin, importance, layer, specificity, and other applicable cascade stages, later source order wins. Two where rules often have zero specificity, making source order the visible tie-breaker. A selector outside where can add weight and change that outcome.
Keep stylesheet and layer order predictable. Build tools may concatenate modules differently from source view, so debug the delivered CSS when equal-weight defaults behave unexpectedly.
When to Choose :is()
Choose is when several alternatives share a rule and their selector weight should participate normally. It is effective for grouping heading types, component variants, control types, interaction states, or repeated contexts. It reduces repetition while retaining the strongest argument specificity.
Avoid mixing an ID with low-weight alternatives unless ID-level strength is intentional. If consumers should override the grouped rule easily, where may better express the contract.
When to Choose :where()
Choose where for defaults, resets, library foundations, broad content styles, and optional context that should not increase selector weight. It lets a selector remain accurately scoped while component or utility classes override it without escalation.
Zero specificity is not always desirable. A where rule can lose to an unrelated type selector, and excessive reliance on source order may make behavior sensitive to bundle ordering. Use it as part of a documented cascade strategy.
When a Normal Selector List Is Better
A short top-level list such as h1, h2, h3 is familiar and calculates specificity per branch. It may be clearer than wrapping everything in is when no repeated prefix or suffix exists. Separate lists also allow comments or declarations to diverge later without unpacking a functional expression.
Use modern functions to solve repetition or cascade design, not merely because they are newer. Readability for the team and stability of the resulting selector matter more than minimizing characters.
Common Mistake: Assuming Both Have Zero Specificity
Only where contributes zero. Is takes the strongest argument. Replacing where with is can make a base rule much harder to override, especially when a class or ID is present in the list. Replacing is with where can make a rule unexpectedly weak.
Code review should treat a function change as a cascade change even if matching remains identical. Verify the strongest is argument and every selector outside where.
Common Mistake: Hiding an ID in :is()
Adding one ID branch to an is list raises the function to ID specificity for every match. Developers may add it for a single exception and unintentionally affect all class or type branches. Later component overrides then begin a specificity escalation.
Split the ID branch into a separate rule, replace it with a class, or use where when the list should be weightless. Selector grouping and specificity architecture cannot be reviewed independently.
Common Mistake: Over-Grouping
Large lists can combine selectors that happen to share declarations today but represent unrelated domains. A future change then requires careful splitting, and a strong argument can alter specificity for every branch. Complex nested functions may be shorter than expanded CSS but harder to debug.
Group stable concepts with a clear common reason. Use component classes for domain roles and separate rules when differences are likely. Maintainability is measured by comprehension and safe change, not only line count.
Common Mistake: Expecting Semantic Behavior
:is(button, .button) can style native buttons and elements carrying a button class, but it does not give the classed element button semantics, keyboard activation, form behavior, or disabled support. Matching different elements together changes only presentation.
Use native button for actions and anchor for navigation. Shared styling can be grouped, but accessibility and behavior must remain correct for each alternative. A class should not be used to disguise an inert div as an interactive control.
Debugging :is() and :where()
Inspect the target element and test each argument selector independently. Confirm surrounding contexts and combinators, then review matched declarations. If one branch is invalid, forgiving parsing may discard it silently while other branches continue to work. A linter can identify the lost branch.
For cascade problems, calculate the strongest is argument, remember where contributes zero, include selectors outside the function, and inspect layers and source order. Browser developer tools show which rule won, but teams should also record why a selector was intentionally weightless or strong.
Using the Selectors in DOM Query APIs
Modern DOM selector APIs such as document.querySelector(), querySelectorAll(), and Element.matches() use CSS selector syntax and can accept is or where when the browser supports them. A query such as panel.querySelectorAll(':is(button, a[href])') can collect common interactive descendants without repeating the panel lookup.
JavaScript queries do not use CSS specificity to choose one result. QuerySelector returns the first match in document order, while querySelectorAll returns all matches; where and is therefore behave identically for matching in these APIs. Invalid complete query syntax can throw an exception, so do not assume stylesheet forgiving behavior protects every JavaScript string. Escape user-provided identifiers and avoid building selectors through unsafe concatenation.
Performance Considerations
Is and where primarily improve authoring and maintainability. Modern browsers optimize selector matching, and replacing repeated lists with a functional selector is not automatically a meaningful runtime performance optimization. Matching breadth and DOM size matter more than character count.
Use the functions when they make intent and cascade behavior clearer. Measure actual style recalculation before changing readable selectors for speculative speed. Reducing unused CSS and unnecessary DOM complexity usually has greater impact.
Browser Support and Progressive Enhancement
Modern browsers support is and where, but old browsers and embedded webviews may have incomplete support. Is also evolved from earlier experimental names. Check the project's actual support policy rather than assuming every deployed environment matches current desktop browsers.
Forgiving lists help when individual arguments are unsupported, but a browser that does not understand the function cannot use the rule. Provide a basic fallback when essential presentation depends on it, and use @supports selector(:is(...)) where conditional enhancement is appropriate.
Accessibility Considerations
Selector grouping does not alter semantics, focus order, names, roles, or state. When grouping links, buttons, fields, or states, ensure every matched element remains appropriate for the declarations. A shared visual treatment must not obscure focus, validation, disabled behavior, or current-page feedback.
Low specificity is useful for accessible defaults because components can refine them, but essential protections should not disappear accidentally. Test keyboard navigation, contrast, forced colors, zoom, touch, and assistive technology after component overrides.
Best Practices
Use is when alternatives share declarations and normal strongest-argument specificity is acceptable. Use where when accurate scope should add no weight. Keep lists conceptually related, avoid mixing IDs with low-weight branches, and account for selectors outside the function.
Prefer ordinary lists when they are clearer, use linters to catch silently discarded branches, and inspect final CSS after nesting or preprocessing. Combine functions only when each serves a distinct matching or specificity purpose. Document zero-specificity foundations and layer ordering in the design system.
Interview Questions and Answers
What do is and where do? They match an element when it satisfies any selector in their argument list. What is the main difference? Is adopts the strongest argument specificity; where contributes zero. Are their lists forgiving? Yes, invalid branches can be discarded while valid branches remain. Do selectors outside where count? Yes.
Why can an ID in is be risky? It gives the complete function ID-level specificity. When is where useful? Resets, defaults, content styles, and library rules designed for easy overrides. Can grouping provide button semantics? No; it changes matching and presentation only. Do these functions guarantee faster CSS? No; their primary benefit is maintainability and cascade control.
Practical Review Checklist
- Confirm every argument belongs to one meaningful group.
- Use
:is()when strongest-argument specificity is intended. - Use
:where()when the grouped portion should add zero specificity. - Calculate selectors outside each function normally.
- Avoid IDs inside
:is()unless ID-level weight is deliberate. - Use forgiving parsing as progressive enhancement, not as a substitute for linting.
- Prefer ordinary selector lists when they are shorter and clearer.
- Inspect nesting expansion, cascade layers, and production source order.
- Keep semantic and accessible behavior correct for every grouped element.
- Test overrides to verify that the chosen specificity contract works.
Key Takeaway
:is() and :where() make selector alternatives concise and allow them to appear inside larger relationships without repeating every prefix and suffix. They use forgiving argument lists and match the same kinds of alternatives, making both valuable for components, forms, content regions, themes, and state grouping.
Their cascade contracts differ sharply. Is carries the strongest argument's specificity; where carries none. Use that difference intentionally: is for grouped selectors whose weight should count, where for scoped defaults designed to yield. Clear grouping, controlled specificity, semantic HTML, linting, and realistic override tests turn these modern functions into maintainable tools rather than compact sources of cascade surprises.