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

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

CSS selectors target specific HTML elements for styling. This basic example locates all <p> paragraph elements and changes the text color to red:

p {  
  color: red;  
}

CSS selectors have become increasingly sophisticated since the introduction of CSS3 more than a decade ago. This tutorial discusses three recent pseudo-class selectors which target elements based on their state.

:is() Pseudo-Class Selector

(Note that older articles may refer to *:matches()* or *:any()* but the CSS standard settled on *:is()*.)

Selectors which target different elements with the same styles can lead to verbose CSS. In this example, <p> paragraph text defaults to black but any <p> within a <main>, <header>, or <footer> is green:

/* default */  
p {  
  color: black;  
}

/* <p> in <main>, <header>, or <footer> */  
main p,  
header p,  
footer p {  
  color: green;  
}

SASS and similar CSS pre-processors permit nesting:

main, header, footer {  
  p {  
    color: green;  
  }  
}

This creates identical CSS code and reduces typing effort but:

The :is pseudo-class provides a native CSS solution:

:is(main, header, footer) p {  
  color: green;  
}

:is() has full support in all modern browsers - just Internet Explorer is missing.

Any number of :is() selectors can go anywhere, and each may contain any number of other sectors. The following code colors <h1>, <h2>, and <p> elements red that are children of a <section> with a class of .primary or .secondary that is not the first child of an <article>:

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

You would require the following six CSS selectors without using :is():

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: red;  
}

Note that :is() can NOT match pseudo-elements such as ::before and ::after:

/* this fails */  
p:is(::before, ::after) {  
  display: block;  
  content: 'pseudo';  
}

:where() Pseudo-Class Selector

The :where() pseudo-class selector syntax is identical to :is(). It has the same level of browser support and will often produce the same result:

:where(main, header, footer) p {  
  color: green;  
}

The difference is specificity:

  • :is() uses the specificity of the most specific selector in its arguments, but
  • :where() has a specificity of zero.

Consider this HTML:

<main>  
  <p>main text</p>  
</main>

The text will become green when applying the following CSS:

main p {  
  color: black;  
}

:is(main, header, footer) p {  
  color: green;  
}

:where(main, header, footer) p {  
  color: blue;  
}

The :is() selector has the same specificity as main p, but it comes later, so the text is green. Blue text is only applied when you remove both the main p and :is() selectors.

:where() has fewer use-cases than :is() but the zero specificity can be useful for CSS resets. Consider this reset which applies a top margin of 1em to <h2> headings unless they're the first child of a <main>:

/* reset */
h2 {  
  margin-block-start: 1em;  
}

main :first-child {  
  margin-block-start: 0;  
}

Applying a custom <h2> top margin later in the CSS has no effect because main :first-child has a higher specificity:

/* this has no effect */  
h2 {  
  margin-block-start: 2em;  
}

Using the zero specificity :where() means that any reset style can be overridden without resorting to additional selectors or !important:

/* reset */  
:where(h2) {  
  margin-block-start: 1em;  
}

:where(main :first-child) {  
  margin-block-start: 0;  
}

/* this now works! */  
h2 {  
  margin-block-start: 3em;  
}

:has() Pseudo-Class Selector

The :has() selector uses a similar syntax to :is() and :where() but targets an element which contains a set of others. That's correct: web developers finally have a way to target parent elements - such as all <a> link anchors which contain an <img> or <div>:

/* styles applied to the <a> element */  
a:has(img, div) {  
  border: 2px solid blue;  
}

This opens possibilities that would have required JavaScript in the past. For example, you can set the style of an outer <fieldset> when any required inner field is not valid and disable any following submit buttons:

/* red border when any required inner field is invalid */  
fieldset:has(:required:invalid) {  
  border: 3px solid red;  
}

/* disable following submit button */  
fieldset:has(:required:invalid) + button\[type='submit'\] {  
  opacity: 0.2;  
  pointer-events: none;  
}

:has() is newer than :is() and :where(). At the time of writing, limited support is available in Safari 15.4+ and Chrome 105+, due for release in late 2022.

Conclusion

The :is() and :where() pseudo-class selectors simplify CSS syntax and lessen the need for a pre-processor such as Sass.

:has() is more exciting. Parent selectors have been among the top CSS developers' wishes for two decades. Still, browser vendors resisted for performance reasons (adding, removing, or modifying child elements can affect the styling of the whole page). We've become used to a world where parent selection was not possible, but it'll rapidly become a popular option when most browsers support :has() in 2023.

Originally published at blog.openreplay.com on August 11, 2022.

Did you find this article valuable?

Support Craig Buckler by becoming a sponsor. Any amount is appreciated!