:has()

Avatar of Mojtaba Seyedi
Mojtaba Seyedi on (Updated on )

UGURUS offers elite coaching and mentorship for agency owners looking to grow. Start with the free Agency Accelerator today.

The CSS :has() pseudo-class selects elements that contain other elements that match the selector passed into its arguments. It’s often referred to as “the parent selector” because of its ability to select a parent element based on the child elements it contains and apply styles to the parent.

/* Select the .card element when it 
   contains a <figure> immediately
   followed by a paragraph. */
.card:has(figure + p) {
  flex-direction: row;
}

This example selects an element with the .card class when it contains a <figure> element that is immediately followed by a <p> element:

<article class="card">
  <figure></figure>
  <p></p>
</article>

This is incredibly useful to write styles for components that may or may not contain certain elements inside, such as a card grid where a card item always has a paragraph, but might not have an image that accompanies it.

Image or no image? That is the question.

That way, in situations where you do not always know what the markup includes, you can still write styles that match those conditions.

:has() is defined in the Selectors Level 4 specification where it is described as “the relational pseudo-class” because of its ability to match selectors based on an element’s relationship to other elements.

Basic usage

The following HTML contains two <button> elements. One of them has an SVG icon.

<!-- Plain button -->
<button>Add</button>

<!-- Button with SVG icon -->
<button>
  <svg></svg>
  Add
</button>

Now, let’s say, you want to apply styles only to the <button> that has an <svg> element inside of it.

:has() is perfectly suited for the job:

button:has(svg) {
  /* Styles */
}

The :has() selector gives us the ability to distinguish between a button that has a descendant <svg>, and one that doesn’t.

Syntax

:has( <unforgiving-relative-selector-list> )

A unforgiving selector list refers to the arguments that are passed into the :has() selector’s arguments, which is a comma-separated list of elements that are evaluated together based on their relationship to the parent element.

article:has(ol, ul) {
  /* Matches an <article> that contains either
     an ordered or unordered list. */
}

We’ll break down the argument list’s “unforgiving” nature in greater detail in just a bit.

Specificity

One of the more interesting aspects of :has() is that its specificity is determined by the most specific element in its argument list. Say we have the following style rules:

article:has(.some-class, #id, img) {
  background: #000;
}

article .some-class {
  background: #fff;
}

We have two rules, both selecting an <article> element to change its background. Which background does this HTML get?

<article class="some-class">
  <div class="some-class"></div>
</article>

You might think it gets a white (#fff) background because it comes later in the cascade. But since the argument list for :has() includes other selectors, we have to consider the most specific one in the list to determine the real specificity of that first rule. That would be #id in this case.

Let’s compare them:

  • article .some-class generates a score of (0,1,1)
  • article:has(.some-class, #id, img) generates a score of (1,0,1)

The first rule wins! The element gets a black (#000) background.

Pop quiz!

What color do you think wins in the following example?

article:has(h1, .title) a { 
  color: red; 
}

article h1 a {
  color: green;
}
Show me the answer!
/* Specificity: (0,1,2) */
article:has(h1, .title) a {
  color: red; /* 🏆 Winner! */
}

/* Specificity: (0,0,3) */
article h1 a {
  color: green;
}

:has() is an “unforgiving” selector

The first draft of specifications introduced :has() as a “forgiving selector”:

:has( <forgiving-relative-selector-list> )

The idea being that a list can contain an invalid selector and ignore it.

/* Example: Do not use! */
article:has(h2, ul, ::-scoobydoo) { }

::-scoobydoo is a completely made-up invalid pseudo-element that doesn’t exist. If :has() was a “forgiving”, that bogus selector would simply be ignored while the rest of the arguments are parsed as normal.

But later on, due to a conflict with jQuery’s behavior, the spec authors decided to define :has() as a non-forgiving selector. As a result, :is() and :where() are the only forgiving relative selectors of the bunch.

That means :has() behaves a lot more like a compound selector. According to CSS specifications, for legacy reasons, the general behavior of a compound selector is that if any selector in the list is invalid, the entire selector list is invalid, resulting in the entire rule set being thrown out.

/* This doesn't do anything because `::-scoobydoo`
   is an invalid selector */
a, a::-scoobydoo {
  color: green;
}

The same is true of :has(). Any invalid selector in its argument list will invalidate everything else in the list. So, that example we looked at earlier:

/* Example: Do not use! */
article:has(h2, ul, ::-scoobydoo) { }

…won’t do anything at all. All three selectors in the argument list are invalidated because of that invalid ::scoobydoo selector. It’s an all-or-nothing sort of thing.

But there’s kind of a workaround for this. Remember, :is() and :where() are forgiving, even if :has() is not. That means we can nest either of the those selectors in :has() to get more forgiving behavior:

p:has(:where(a, a::scoobydoo)) {
  color: green;
}

So, if you ever want the :has() to work as a “forgiving” selector, try nesting :is() or :where() inside of it.

The argument list accepts complex selectors

A complex selector contains one or more compound selectors (e.g. a.fancy-link) and combinators (e.g. >+~). The argument list for :has() accepts these comple selectors and can be used to identify relationships between multiple elements.

<relative-selector> = <combinator>? <complex-selector>

Here is an example of a relative selector that contains a complex selector with the child combinator (>). It selects elements with an .icon class that are direct children of links that have a .fancy-link class and are in a :focus state:

a.fancy-link:focus > .icon {
  /* Styles */
}

This sort of thing can be used directly in the :has() argument list:

p:has(a.fancy-link:focus > .icon) {
  /* Styles */
}

But instead of selecting .icon elements that are direct children of .fancy-class links that are in :focus, we’re styling paragraphs that have focused .fancy-links with direct children that have an .icon class.

Phew, trying saying that three times fast! Maybe it helps to see a markup example that matches:

It generally doesn’t support pseudo-selectors

I say that :has() does not “generally” support other pseudo-elements in its arguments because that’s exactly what it says in the spec:

Note: Pseudo-elements are generally excluded from :has() because many of them exist conditionally, based on the styling of their ancestors, so allowing these to be queried by :has() would introduce cycles.

There are indeed a few “has-allowed pseudo-elements” that are allowed in the :has() list of arguments. The specification offers the following example showing how :not() is used alongside :has():

/* Matches any <section> element that contains 
   anything that’s not a heading element. */
section:has(:not(h1, h2, h3, h4, h5, h6))

Jhey Tompkins offers another example showing how :has()can be used to style forms based on various input states, such as :valid, :invalid, and :placeholder-shown:

label {
  color: var(--color);
}
input {
  border: 4px solid var(--color);
}

.form-group:has(:invalid) {
  --color: var(--invalid);
}

.form-group:has(:focus) {
  --color: var(--focus);
}

.form-group:has(:valid) {
  --color: var(--valid);
}

.form-group:has(:placeholder-shown) {
  --color: var(--blur);
}

It can’t nest itself, but it does support chaining

Sorry, there’s no nesting :has() within :has() a la:

/* Nesting is a no-go */
.header-group:has(.subtitle:has(h2)) {
  /* Invalid! */
}

That would create an infinite loop where the specificity is being evaluated inside another evaluation. It does, however, let you chain arguments:

h2,
.subtitle {
  margin-block-end: 1.5rem;
}

/* Reduce spacing on header because the subtitle will handle it */
.header-group:has(h2):has(.subtitle) h2 {
  margin-block-end: 0.2rem;
}

Chaining works like the AND logical operation in that both conditions have to match in order for the style rule to take effect. Suppose you have a list of .news-articles and the articles it contains are categorized. Maybe you want to apply certain styles to the list but only if it contains articles with .breaking-news and articles with .featured-news, but leave it unchanged if only one or none of the articles match those classes.

Well, you can chain two :has() declarations for that conditional styling:

.news-list:has(.featured-news):has(.breaking-news) { 
  /* Styles */ 
}

This example is specific only to the .news-list container. If we wanted to match any ol’ parent element that :has() both the .featured-news and .breaking-news classes on articles, we could leave out the .news-list altogether:

:has(.featured-news):has(.breaking-news) { 
  /* Styles */ 
}

It’s more than a “parent” selector

Jhey Thompkins calls it the “family” selector which might be a more fitting description, particularly when it comes to that last example we looked at. Let’s look at it again:

.header-group:has(h2):has(.subtitle) h2 {
  margin-block-end: 0.2rem;
}

We’re not just selecting an element with the .header-group class that contains an <h2> element. That’s the parent-selecting powers normally ascribed to :has(). What we’re selecting is an element:

  • with a .subtitle class
  • that is a child of the .header-group
  • and contains an <h2> element inside of it.

Is <h2> the direct child of the .header-group? Nope, it’s more like a grandchild.

Combining :has() with other relational pseudo-selectors

You can combine :has() with other functional pseudo-class selectors such as :where():not(), and :is().

Combing :has() and :is()

For instance, you can check whether any of the HTML headings has at least one <a> element as a descendant:

:is(h1, h2, h3, h4, h5, h6):has(a) {
  color: blue;
}

/* is equivalent to: */
h1:has(a),
h2:has(a),
h3:has(a),
h4:has(a),
h5:has(a),
h6:has(a) {
  color: blue;
}

You can also pass :is() as an argument to :has(). Imagine if we changed that last example so that any heading level that contains an <a> child element or any child element with the .link class is selected:

:is(h1, h2, h3, h4, h5, h6):has(:is(a, .link)) {
  color: blue;
}

Combining :has() and :not()

We can use :has() with the :not() selector too! Let’s say you want to add a border to a .card element if it doesn’t contain any <img> element descendant. Sure thing:

.card:not(:has(img)) {
  border: 1px solid var(--my-amazing-color);
}

This checks if the card :has() any image then says:

Hey, if you don’t find any images, pretty please with a cherry on top apply these styles.

Wanna get crazier? Let’s select any .post element for images that are missing alt text:

.post:has(img:not([alt])) {
  /* Styles */
}

See what we did here? This time, :not() is in :has(). This is saying:

Hey, if you find any posts that contain an image without alternative text, apply these styles please and thank you.

This could be used to debug images that are missing the alt attribute:

Here’s another example I borrowed from Eric Meyer’s video. Let’s say, you want to select any <div> that has nothing but <img> elements.

div:not(:has(:not(img))) {
  /* Styles */
}

What we’re saying here is:

If you find a <div> and there’s nothing in there but one or more images, do your magic!

These are situations where order matters

Notice how changing the order of our selectors changes what they select. We talked about the unforgiving nature of the:has()argument list, but it’s even less forgiving in the examples we’ve looked at that combine :has() with other relational pseudo-selectors.

Let’s look at an example:

article:not(:has(img)) {
  /* Styles */
}

This matches any <article> elements that don’t contain any images. Now let’s flip things around so that :has() comes before :not():

article:has(:not(img)) {
  /* Styles */
}

Now we’re matching any <article> element that contains anything as long as there are no images in there. The article has to have a descendant to match, and that descendent can be anything but an image.

Use cases

Breadcrumb separators

Breadcrumbs are a handy way of showing what page a user is currently on and where that page fits in the sitemap. Like, if you are on an About page, you might show a list that contains an item with a link to the Homepage and an item that merely indicates the current page:

<ol class="breadcrumb">
  <li class="breadcrumb-item"><a href="/">Home</a></li>
  <li class="breadcrumb-item current">About</li>
</ol>

That’s cool. But what if we want to display that as a horizontal list and hide the list numbers? Easy enough with CSS:

ol {
  display: flex;
  list-style: none;
}

Heads up! Setting list-style: none prevents Safari from identifying the element as a list.

OK, but now we’re left with two list items that run into each other. We could add a gap between them since we’re using Flexbox:

ol {
  display: flex;
  gap: .5rem;
  list-style: none;
}

That certainly helps. But we can draw a stronger disinction between the two items by adding a separator between them. No big deal:

.breadcrumb-item::after {
  content: "/";
}

But wait! We don’t need a separator after the .current item because it is always last in the list and has nothihng after it. This is where :has() comes into play. We can look for any child with the .current class using the subsequent child combinator (~) to sniff it out:

.breadcrumb-item:has(~ .current)::after {
  content: "/";
}

There we go!

JavaScript-free form validation

As we learned earlier, :has() doesn’t accept pseudo-elements, but it does allow us to use pseudo-classes. We can use this as a light form of validation that we might usually tackle with JavaScript.

Let’s say we have a newsletter signup form that asks for an email:

<form>  
  <label for="email-input">Add your pretty email:</label>
  <input id="email-input" type="email" required>
</form>

Email is a required field in this form. Otherwise, there’s nothing to submit! Maybe we can add a red border to the input if the user enters an invalid email address:

form:has(input:invalid) {
  border: 1px solid red;
}

Give it a rip. Try entering an invalid email address, then tab or click to the Password field.

Styling completed items in a to-do list

Have you tried to style a checkbox’s label when the input is checked? You know, like a to-do list app where you complete items in the list by checking a box.

The structure of your HTML might look like this:

<form>
  <input id="example-checkbox" type="checkbox">
  <label for="example-checkbox">We need to target this when input is checked</label>
</form>

Although it’s better to put the label before or wrap it around the input element for the sake accessibility, you have to put the label after the input to be able to select the label based on the input’s check attribute.

Using a next sibling combinator (+), you can style label like this:

/* When the input is checked, 
  style the label */
input:checked + label {
  color: green;
}

Let’s change the markup and create an implecit label by nesting the input in it:

<form>  
  <label>
    <input type="checkbox">
    We need to target this when input is checked
  </label>
</form>

There used to be no way to select that label when the input is checked. But now that we have the :has() selector, we totally have thqt power:

/* If a label has a checked input,
  style that label */
label:has(input:checked) {
  color: green;
}

Now, let’s go back to the ideal markup where the label comes before the input:

<form> 
  <label for="example-checkbox">We need to target this when input is checked</label>
  <input id="example-checkbox" type="checkbox">
</form>

You can still use :has() as a previous selector to select and style the explicit label while maintaining more accessible markup:

/* If a label has a checked input 
   that is it's next sibling, style 
   the label */
label:has(+ input:checked) {
  color: green;
}
Smart “Add to cart” button

What happens is we apply :has() to a page’s root element or body?

:root:has( /* Any condition */ ) {
  /* Styles */
}

The :root is the highest level of a document, which controls of everything under it, right? If something happens somewhere far below in the DOM tree, you can detect it and style another branch of the DOM accordingly.

Let’s say you run an e-commerce site and you want to style the “Add to cart” button when a product is added to the cart. This is pretty common, right? Sites like Amazon do this all the time to let the user know an item is successfully in their cart.

Imagine this is the structure of our HTML. The “Add to cart” button is contained in the <header> element and the product is contained in a <main> element.

A diagram that identifies where the product and button items are locted in the DOM tree.

A super simplified example of the markup might look like this:

<body>
  <header>
    <button class="cart-button">Add to cart</button>
  </header>
  <main> 
    <ul>
      <li class="p-item">Product</li>
      <li class="p-item is-selected">Product</li>
      <li class="p-item">Product</li>
    </ul>
  </main>
</body>

In CSS, we can check if the <body> :has() any descendant with both the .p-item and .is-selected classes. Once this condition is true, the .cart-button can be selected:

body:has(.p-item.is-selected) .cart-button {
  background-color: green;
}
Changing color themes

Dark mode, light mode, high-contrast mode. Providing users a choice to customize a site’s color theme can be a nice UX enhancement.

Let’s say somewhere deep in the document you have a <select> menu for users to choose a color theme:

<select>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
  <option value="high-contrast">High-contrast</option>
</select>

We can use :has() on the <body> element and check for the <select> menu’s selected <option>. That way, if an <option> contains a certain value, we can update CSS custom properties with different color values to change the current color theme:

body:has(option[value="dark"]:checked) {
  --primary-color: #e43;
  --surface-color: #1b1b1b;
  --text-color: #eee;
}

Again, something is happening (a user <select>s an <option> ) somewhere down the DOM tree, and we watch for changes at the highest level of the tree (the <body>) and update the styles (via ccustom properties) accordingly.

Style an element based on the number of children

Here’s a clever one that comes by way of Bramus Van Damme. :has() can apply styles based on the number of children in a parent container.

Imagine you have two-column layout. If the number items in the grid is os — 3, 5, 7, 9, etc. — then you’re stuck with an empty space in the grid after the last item.

Adjusting a two-column layout with an odd number of children where the first child spans the first row.

It would be better if the first item in the grid could take up the two columns in the first row to prevent that from happening. And for that, you’d need to check whether the :last-child element in the grid is also an odd-numbered child:

/* If the last item in a grid is an odd-numbered child */
.grid-item:last-child:nth-child(odd) {
  /* Styles */
}

This can be passed into the :has() argument list so we can style the grid’s :first-child so it takes up the entire first row of the grid when the :last-child is an odd number:

.grid:has(> .grid-item:last-child:nth-child(odd)) .grid-item:first-child {
  grid-column: 1 / -1;
}

Browser support

This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.

Desktop

ChromeFirefoxIEEdgeSafari
105NoNo10515.4

Mobile / Tablet

Android ChromeAndroid FirefoxAndroidiOS Safari
115No11515.4

Testing for support

The @supports at-rule supports :has() meaning we can check whether a browser supports it and apply styles conditionally based on the result:

@supports(figure(:has(figcaption))) {
  /* Supported! */
}

More information