For most of CSS's history, selectors could only look down and forwards: you could style a child based on its parent, but never a parent based on its child. :has() changes that. :is() and :where() are its quieter companions: they shorten long selector lists and let you choose exactly how strong a selector is.
:is(): shorter selector lists
:is() takes a list of selectors and matches any of them:
/* Without :is() */
.article h2 a,
.article h3 a,
.article h4 a {
color: inherit;
}
/* With :is() */
.article :is(h2, h3, h4) a {
color: inherit;
}
The saving grows when there are lists in several places. :is(header, footer) :is(a, button) would otherwise need four full selectors.
Specificity of :is()
:is() takes the specificity of its most specific argument, even if a less specific one is the one that matched:
:is(#main, .sidebar) p {
color: #334e68; /* (1, 0, 1) for every match, even inside .sidebar */
}
That can make a rule much stronger than you expect. Mixing an ID with classes inside :is() is best avoided.
Forgiving lists
In a normal selector list, one invalid selector discards the whole rule. :is() and :where() use a forgiving list: invalid or unsupported selectors inside them are ignored and the rest still work.
:is(.card, :unknown-pseudo) {
border: 1px solid #d9e2ec; /* still applies to .card */
}
:where(): zero specificity
:where() works exactly like :is(), but always has a specificity of zero, whatever is inside it:
:where(.article) a {
color: #0b5394; /* specificity (0, 0, 1): only the "a" counts */
}
a.special {
color: #b42318; /* (0, 1, 1): easily wins */
}
That makes :where() perfect for defaults that should be trivial to override:
/* Base styles that any class can override */
:where(ul, ol)[role="list"] {
list-style: none;
padding: 0;
}
:where(button, input, select, textarea) {
font: inherit;
}
Libraries and resets increasingly wrap their selectors in :where() so that your styles win without fighting. Use it the same way for your own base layer: low-specificity defaults, components on top.
| Selector | Matches | Specificity |
|---|---|---|
:is(a, b) | a or b | The most specific argument |
:where(a, b) | a or b | Always zero |
:not(a, b) | Neither a nor b | The most specific argument |
:has(a) | Elements containing a match | The most specific argument |
:has(): the relational selector
:has() matches an element if the selector inside it matches relative to that element. It is often called the parent selector, though it can do more.
/* A card that contains an image */
.card:has(img) {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 1rem;
}
Cards with an image become a two-column layout; cards without stay as they are. No extra class is needed in the HTML.
Children, siblings and states
The argument is a relative selector. It can start with a combinator:
/* A section whose direct child is an h2 */
section:has(> h2) {
padding-top: 2rem;
}
/* A heading immediately followed by a paragraph */
h2:has(+ p) {
margin-bottom: 0.5rem;
}
The second example finally lets you style an element based on what comes after it.
:has() becomes especially powerful with state pseudo-classes:
/* Highlight a field when its checkbox is checked */
.option:has(input:checked) {
border-color: #0b5394;
background-color: #eef4fb;
}
/* Show an error style on a field group after the user enters invalid input */
.field:has(input:user-invalid) {
--field-border: #b42318;
}
/* Disable page scrolling while a modal dialog is open */
body:has(dialog[open]) {
overflow: hidden;
}
These used to need JavaScript to add and remove classes. Now the styling follows the state automatically.
Quantity queries
:has() can react to how many children there are:
/* A list with more than five items gets two columns */
.tag-list:has(> :nth-child(6)) {
columns: 2;
}
If a sixth child exists, the list has more than five items.
Global state from anywhere
Because you can apply :has() to :root or body, any control can drive page-wide styles:
<label>
<input type="checkbox" id="compact"> Compact view
</label>
:root:has(#compact:checked) {
--space-m: 0.5rem;
}
Every component using --space-m tightens up when the box is ticked.
Limitations and performance
:has()cannot be nested inside another:has().- Pseudo-elements such as
::beforecannot be used inside:has(). - Unlike
:is()and:where(), the argument list of:has()is not forgiving. One invalid selector inside it invalidates the whole rule.
Browsers have optimised :has() heavily, and normal use is fine. Very broad patterns on large pages, such as *:has(*) or :has() on body with descendant selectors that change constantly, can make style recalculation slower. Anchor it to a specific element, such as .card:has(...), and prefer child (>) or sibling (+) combinators when they express what you mean.
Browser support and progressive enhancement
:is() and :where() have been supported in all major browsers for several years. :has() is newer. It is now supported in all current major browsers, but older versions do not understand it, and they drop any rule that uses it.
Write styles that work without :has(), then add it as an enhancement:
/* Works everywhere */
.option {
border: 1px solid #d9e2ec;
}
/* Enhancement */
.option:has(input:checked) {
border-color: #0b5394;
}
You can also test for support explicitly with @supports selector():
@supports selector(:has(a)) {
.card:has(img) {
grid-template-columns: 8rem 1fr;
}
}
Do not put a :has() selector in the same selector list as selectors you need everywhere, such as .option.is-checked, .option:has(input:checked). If a browser does not support :has(), the whole list is invalid and the fallback class stops working too. Write two separate rules, or wrap the list in :is() to make it forgiving.
Check Baseline status on web.dev for the exact support picture for your audience.
Debugging
- Inspect the element that
:has()is attached to (the parent), not the child that triggers it. - In the Styles pane, the rule appears only when the condition is currently true. Toggle the checkbox or state and watch it appear.
- Hover a selector to see its specificity, which helps with
:is()and:where()surprises.
Summary
:is()shortens selector lists and takes the specificity of its strongest argument.:where()does the same with zero specificity, ideal for easily overridden defaults.:is()and:where()are forgiving;:has()is not.:has()styles an element based on its descendants, siblings and their states.- Treat
:has()as an enhancement; keep it out of selector lists that must always work.