Modern selectors: :has(), :is() and :where()

Style a parent based on what it contains, and write shorter selectors with precise control over specificity.

  • Beginner
  • 7 min read
  • Updated 17 Oct 2026

What you will learn

  • Style a parent based on its children
  • Shorten selector lists with :is()
  • Control specificity with :where()
  • Use :has() with progressive enhancement

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:

CSS
/* 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:

CSS
: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.

CSS
: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:

CSS
: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:

CSS
/* 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.

SelectorMatchesSpecificity
:is(a, b)a or bThe most specific argument
:where(a, b)a or bAlways zero
:not(a, b)Neither a nor bThe most specific argument
:has(a)Elements containing a matchThe 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.

CSS
/* 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:

CSS
/* 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:

CSS
/* 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:

CSS
/* 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:

HTML
<label>
    <input type="checkbox" id="compact"> Compact view
</label>
CSS
: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 ::before cannot 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:

CSS
/* 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():

CSS
@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.

Further reading