Both :is() and :where() CSS pseudo-class functions take a <forgiving-selector-list> as their argument, and select any element that can be selected by one of the selectors in that list.

:is(<forgiving-selector-list>) {
  /* ... */
}

:where(<forgiving-selector-list>) {
    /* ... */
}

Using them can come with visible improvements related to the amount of code written by reducing repetition, making things simple, less verbose, and less error-prone.

What is a forgiving-selector-list?

You’ve probably seen in practice that the general behavior of a selector list is that if any selector in the list fails to parse, the entire selector list becomes invalid. It can be due to a mistake or not necessarily - think about the case when you want to use a new or User-Agent-specific selector. This situation can make it hard to write CSS that uses new selectors and still works correctly in older browsers.

Here’s where a forgiving-selector-list comes to help: it parses each selector in the list individually, simply ignoring ones that fail to parse, so the remaining selectors can still be used.

That means:

:is(:valid, :unsuported) {
    /*...*/
}

Will still parse correctly and match :valid even in browsers which don’t support :unsupported, whereas:

:valid,
:unsupported {
    /*...*/
}

Will be ignored in browsers that do not support :unsupported, even if they support :valid.

Syntactically and behaviorally speaking, nothing changes - style rules still use the normal, unforgiving selector list behavior, and the enumerated list provided as an argument for either :is() or :where() looks the same as an unforgiving selector list. The magic happens automatically used behind the scene.

The only catches are related to how specificity is computed and the fact that pseudo-elements are not valid in these kinds of selector lists.

Let’s proceed with discussing each of the utilities individually and give some concrete code examples so that things get clear.

:is()

Starting short, the :is() pseudo-class can greatly simplify your CSS selectors, allowing you to write composed ones more compactly. For example, rather than writing:

ul li,
ol li { }

we can replace it with:

:is(ul, ol) li {}

Now, let’s see how would this apply at scale:

:is(ol, ul, menu, dir) :is(ol, ul, menu, dir) :is(ul, menu, dir) {
    list-style-type: square;
}

/* is the equivalent of the following 3-deep level enumeration for the same purpose */

/* 3-deep (or more) unordered lists use a square */
ol ol ul,
ol ul ul,
ol menu ul,
ol dir ul,
ol ol menu,
ol ul menu,
ol menu menu,
ol dir menu,
ol ol dir,
ol ul dir,
ol menu dir,
ol dir dir,
ul ol ul,
ul ul ul,
ul menu ul,
ul dir ul,
ul ol menu,
ul ul menu,
ul menu menu,
ul dir menu,
ul ol dir,
ul ul dir,
ul menu dir,
ul dir dir,
menu ol ul,
menu ul ul,
menu menu ul,
menu dir ul,
menu ol menu,
menu ul menu,
menu menu menu,
menu dir menu,
menu ol dir,
menu ul dir,
menu menu dir,
menu dir dir,
dir ol ul,
dir ul ul,
dir menu ul,
dir dir ul,
dir ol menu,
dir ul menu,
dir menu menu,
dir dir menu,
dir ol dir,
dir ul dir,
dir menu dir,
dir dir dir {
  list-style-type: square;
}

###is() does not select pseudo-elements

As mentioned above, one important thing to remember is that :is() does not match pseudo-elements. For example, in the particular case of wanting to write something like this:

some-element:is(::before, ::after) {
    display: block
}

you actually need to specify the selectors like this:

some-element::before,
some-element::after {
    display: block;
}

:where() vs :is()

What about :where()? It works in the same way as :is(), just that in this particular case the specificity of the structure counts as 0, compared to is:() pseudo-class where the specificity equals the specificity of its most specific argument. This also means that a selector written with :is() does not necessarily have the same specificity as the equivalent selector written without :is(). Example:

/* This has higher precedence... */
:is(ol, .list, ul) li { /* ... */ }

/* ...than this, even though this is later... */
ol li  { /* ... */ }

/* ...because :is() has the weight of its heaviest selector, which is `.list`! */

Now take an example with :where():

main: where(h1, h2, h3) {
  color: orange;
}

The specificity of this will be (0,0,1) as it is treated like a single element selector, whereas the specificity of the equivalent selector written without :where() will be (0,0,2), because there are two element selectors:

main h1, main h2, main h3 {
  color: orange;
}

Check this for a better understanding of how specificity is computed.

Should I use :is() or :where() ?

In general, the lower the specificity is, the fewer the headaches will be.

An interesting use-case that I’ve found is given as an example by Chris Coyer in this article:

h3:where(#specific-header) {
  outline: 1px solid yellow;
}

He uses :where() to select a header by ID, without having to resort to ID-level specificity. As IDs have very high specificity - for each ID in a matching selector (1-0-0) is added to the weight value -, they’re hard to override, but in this case, he succeeded to achieve the same but the specificity of the ID is ignored, obtaining a value of (0-0-1) for the entire selector.

Browsers compatibility

References