Grouping selectors without the selector soup

Long selector lists are one of those CSS habits that never feel great. You write the same rule for several targets, each one separated by a comma, and the result is a block that takes a moment to parse. :is() and :where() are functional pseudo-classes that let you fold those lists into a single, readable selector.

Say you want to style every <b> tag inside a heading:

h1 > b, h2 > b, h3 > b, h4 > b, h5 > b, h6 > b {
  color: hotpink;
}

With :is(), the same rule becomes:

:is(h1,h2,h3,h4,h5,h6) > b {
  color: hotpink;
}

The pair are functionally similar in most respects, but they diverge sharply on one axis: specificity. That single difference decides when you reach for each one.

Where you can use them

Unlike a conventional selector list, which has to sit at the end of a rule, :is() and :where() are valid at the beginning, middle, or end of a selector. The ability to nest and stack them also holds:

/* at the beginning */
:where(h1,h2,h3,h4,h5,h6) > b {
  color: hotpink;
}

/* in the middle */
article :is(header,footer) > p {
  color: gray;
}

/* at the end */
.dark-theme :where(button,a) {
  color: rebeccapurple;
}

/* multiple */
:is(.dark-theme, .dim-theme) :where(button,a) {
  color: rebeccapurple;
}

/* stacked */
:is(h1,h2):where(.hero,.subtitle) {
  text-transform: uppercase;
}

/* nested */
.hero:is(h1,h2,:is(.header,.boldest)) {
  font-weight: 900;
}

You can also pass complex selectors inside the parentheses, not just simple type or class matches:

article > :is(p,blockquote) {
  color: black;
}

:is(.dark-theme.hero > h1) {
  font-weight: bold;
}

article:is(.dark-theme:not(main .hero)) {
  font-size: 2rem;
}

The practical upshot: if you spot a comma-heavy selector with repeated parts, that is a candidate for one of these functional pseudo-classes.

The specificity split

Here is where the two stop being interchangeable:

  • :where() contributes zero specificity. Whatever sits inside the parentheses — IDs included — is flattened away. This is the first selector feature that can actively discard specificity.
  • :is() adopts the specificity of its most specific argument. So :is(a,div,#id) scores as an ID selector, no matter how many low-specificity items are listed.

That last behavior can surprise you if you group a stray ID into a list of classes and elements:

article > :is(header, #nav) {
  background: white;
}

/* better as */
article > header,
article > #nav {
  background: white;
}

The fix, when your rule does not actually depend on that high-specificity item, is to split it out into its own declaration.

Zero specificity, on the other hand, is a quiet revolution for libraries. Author styles have always had to fight library rules on specificity terms; if a library ships variants of its components wrapped in :where(), there is no score to compete with at all. Your own styles can override the default without needing heavier selectors or !important.

Browser support

Both selectors are supported from the same versions across the major engines:

  • Chrome 88
  • Edge 88
  • Firefox 78
  • Safari 14

With that baseline, the feature worth experimenting with now is :where(). The ability to ship styles that intentionally carry no specificity weight opens a design space CSS has not had before: smaller stylesheets, fewer commas, and an end to specificity arms races with third-party code.