How the CSS :is, :where and :has Pseudo-class Selectors Work

CSS selectors allow you to choose elements by type, attributes, or location within the HTML document. This tutorial explains three new options — :is(), :where(), and :has().

Selectors are commonly used in stylesheets. The following example locates all <p> paragraph elements and changes the font weight to bold:

p { font-weight: bold;
}

You can also use selectors in JavaScript to locate DOM nodes:

Pseudo-class selectors target HTML elements based on their current state. Perhaps the most well known is :hover, which applies styles when the cursor moves over an element, so it’s used to highlight clickable links and buttons. Other popular options include:

  • :visited: matches visited links
  • :target: matches an element targeted by a document URL
  • :first-child: targets the first child element
  • :nth-child: selects specific child elements
  • :empty: matches an element with no content or child elements
  • :checked: matches a toggled-on checkbox or radio button
  • :blank: styles an empty input field
  • :enabled: matches an enabled input field
  • :disabled: matches a disabled input field
  • :required: targets a required input field
  • :valid: matches a valid input field
  • :invalid: matches an invalid input field
  • :playing: targets a playing audio or video element

Browsers have recently received three more pseudo-class selectors…

The CSS :is Pseudo-class Selector

Note: this was originally specified as :matches() and :any(), but :is() has become the CSS standard.

You often need to apply the same styling to more than one element. For example, <p> paragraph text is black by default, but gray when it appears within an <article>, <section>, or <aside>:


p { color: #000;
} article p,
section p,
aside p { color: #444;
}

This is a simple example, but more sophisticated pages will lead to more complicated and verbose selector strings. A syntax error in any selector could break styling for all elements.

CSS preprocessors such as Sass permit nesting (which is also coming to native CSS):

article, section, aside { p { color: #444; } }

This creates identical CSS code, reduces typing effort, and can prevent errors. But:

  • Until native nesting arrives, you’ll need a CSS build tool. You may want to use an option like Sass, but that can introduce complications for some development teams.

  • Nesting can cause other problems. It’s easy to construct deeply nested selectors that become increasingly difficult to read and output verbose CSS.

:is() provides a native CSS solution which has full support in all modern browsers (not IE):

:is(article, section, aside) p { color: #444;
}

A single selector can contain any number of :is() pseudo-classes. For example, the following complex selector applies a green text color to all <h1>, <h2>, and <p> elements that are children of a <section> which has a class of .primary or .secondary and which isn’t the first child of an <article>:

article section:not(:first-child):is(.primary, .secondary) :is(h1, h2, p) { color: green;
}

The equivalent code without :is() required six CSS selectors:

article section.primary:not(:first-child) h1,
article section.primary:not(:first-child) h2,
article section.primary:not(:first-child) p,
article section.secondary:not(:first-child) h1,
article section.secondary:not(:first-child) h2,
article section.secondary:not(:first-child) p { color: green;
}

Note that :is() can’t match ::before and ::after pseudo-elements, so this example code will fail:


div:is(::before, ::after) { display: block; content: ''; width: 1em; height: 1em; color: blue;
}

The CSS :where Pseudo-class Selector

:where() selector syntax is identical to :is() and is also supported in all modern browsers (not IE). It will often result in identical styling. For example:

:where(article, section, aside) p { color: #444;
}

The difference is specificity. Specificity is the algorithm used to determine which CSS selector should override all others. In the following example, article p is more specific than p alone, so all paragraph elements within an <article> will be gray:

article p { color: #444; }
p { color: #000; }

In the case of :is(), the specificity is the most specific selector found within its arguments. In the case of :where(), the specificity is zero.

Consider the following CSS:

article p { color: black;
} :is(article, section, aside) p { color: red;
} :where(article, section, aside) p { color: blue;
}

Let’s apply this CSS to the following HTML:

<article> <p>paragraph text</p>
</article>

The paragraph text will be colored red, as shown in the following CodePen demo.

See the Pen Using the :is selector by SitePoint (@SitePoint)
on CodePen.

The :is() selector has the same specificity as article p, but it comes later in the stylesheet, so the text becomes red. It’s necessary to remove both the article p and :is() selectors to apply a blue color, because the :where() selector is less specific than either.

More codebases will use :is() than :where(). However, the zero specificity of :where() could be practical for CSS resets, which apply a baseline of standard styles when no specific styling is available. Typically, resets apply a default font, color, paddings and margins.

This CSS reset code applies a top margin of 1em to <h2> headings unless they’re the first child of an <article> element:


h2 { margin-block-start: 1em;
} article :first-child { margin-block-start: 0;
}

Attempting to set a custom <h2> top margin later in the stylesheet has no effect, because article :first-child has a higher specificity:


h2 { margin-block-start: 2em;
}

You can fix this using a higher-specificity selector, but it’s more code and not necessarily obvious to other developers. You’ll eventually forget why you required it:


article h2:first-child { margin-block-start: 2em;
}

You can also fix the problem by applying !important to each style, but please avoid doing that! It makes further styling and development considerably more challenging:


h2 { margin-block-start: 2em !important;
}

A better choice is to adopt the zero specificity of :where() in your CSS reset:


:where(h2) { margin-block-start: 1em;
} :where(article :first-child) { margin-block-start: 0;
}

You can now override any CSS reset style regardless of the specificity; there’s no need for further selectors or !important:


h2 { margin-block-start: 2em;
}

The CSS :has Pseudo-class Selector

The :has() selector uses a similar syntax to :is() and :where(), but it targets an element which contains a set of others. For example, here’s the CSS for adding a blue, two-pixel border to any <a> link that contains one or more <img> or <section> tags:


a:has(img, section) { border: 2px solid blue;
}

This is the most exciting CSS development in decades! Developers finally have a way to target parent elements!

The elusive “parent selector” has been one of the most requested CSS features, but it raises performance complications for browser vendors, and therefor has been a long time coming. In simplistic terms:

  • Browsers apply CSS styles to an element when it’s drawn on the page. The whole parent element must therefore be re-drawn when adding further child elements.

  • Adding, removing, or modifying elements in JavaScript could affect the styling of the whole page right up to the enclosing <body>.

Assuming the vendors have resolved performance issues, the introduction of :has() permits possibilities that would have been impossible without JavaScript in the past. For example, you can set the styles of an outer form <fieldset> and the following submit button when any required inner field is not valid:


fieldset:has(:required:invalid) { border: 3px solid red;
} fieldset:has(:required:invalid) + button[type='submit'] { opacity: 0.2; cursor: not-allowed;
}

Fieldset shown with a red border and submit button disabled

This example adds a navigation link submenu indicator that contains a list of child menu items:


nav li:has(ol, ul) a::after { display: inlne-block; content: ">";
}

Or perhaps you could add debugging styles, such as highlighting all <figure> elements without an inner img:


figure:not(:has(img)) { border: 3px solid red;
}

Five images in a row, with a red border around the missing one

Before you jump into your editor and refactor your CSS codebase, please be aware that :has() is new and support is more limited than for :is() and :where(). It’s available in Safari 15.4+ and Chrome 101+ behind an experimental flag, but it should be widely available by 2023.

Selector Summary

The :is() and :where() pseudo-class selectors simplify CSS syntax. You’ll have less need for nesting and CSS preprocessors (although those tools provide other benefits such as partials, loops, and minification).

:has() is considerably more revolutionary and exciting. Parent selection will rapidly become popular, and we’ll forget about the dark times! We’ll publish a full :has() tutorial when it’s available in all modern browsers.

If you’d like to dig in deeper to CSS pseudo-class selectors — along with all other things CSS, such as Grid and Flexbox — check out the awesome book CSS Master, by Tiffany Brown.