Dec 23

CSS :has()

Learn the ins and outs of `:has()` which is the new CSS relational selector for selecting parents, siblings, and other unique combinations.

By Estelle Weyl

Developers have been requesting a parent selector since jQuery introduced .has() in 2007, and it is finally here. At the time of this writing, it is behind a flag in Firefox and supported in Chrome, Edge, and Safari.  

The newly supported `:has()` is more than just a parent selector; it’s a relational selector. Yes, it can match an ancestor element based on containing a specific descendant, but it can also match antecedent elements based on subsequent content. 

The long-supported :nth-last-of-type() selects an element based on having sibling selectors of the same type. In the following pseudo-code, you can select any h2 that has a later sibling h2. For example, h2:nth-last-of-type(n+2) selects the first two <h2> elements (the first byline and first subheader).

<main>
<h1 data-function=”mainheader”>
<h2 data-function=”byline”>
<p>
<h2 data-function=”subheader”>
<p>
<h3 data-function=”subheader”>
<p>
<h2 data-function=”subheader”>
<h3 data-function=”byline”>
<p>
</main>

While it has been possible to select previous siblings of the same type with `:nth-last-of-type()`, :has() enables selecting previous siblings of any type. 

Given the pseudo-HTML above, how can we target subheaders immediately followed by a byline? h2 + h3 matches the byline, but how do we target only the subheaders that have an adjacent byline?  With h2:has( + h3)!

Let’s break that down.

The :has() functional pseudo-class takes a relative selector list as an argument. Let’s parse those words: functional selector, selector list, and relative selector. We will also cover forgiving selector lists which come into play until :has() has full browser support.

Functional pseudo-class

While there are several selectors with parenthesis, including the linguistic pseudo-classes (:dir() and :lang()) and structural pseudo-classes (like :nth-last-of-type()), there are four selectors considered to be “functional selectors”.

The negation (:not()), a relational selector (:has()), the matches-any (:is()) , and the specificity adjustment (:where()) pseudo-classes each take a selector list as their argument.

Selector List

A selector list is a comma-separated list of selectors.

It is important to note that if any element in a selector list is invalid, the entire list and associate style block are ignored.

/* These are valid */
body, *, #unusedId {color: yellow;}
body, *, .unused-class {color: blue;}
body, *, undefined-element {color: black;}

/* These are all invalid */
body, *, #00invalidIdName {color: green;}
body, *, :fake-pseudo-class {color:red;}
body, *, ::invalid-pseudo-element {color: purple;}

Given the above code, everything on the page will be black. If an id is a valid id, a class is a valid class, or a custom element is not defined, and there are no matching elements in the HTML, the CSS doesn’t know or care as there are no matching elements. But pseudo-classes and pseudo-elements can be invalid; :foo and ::-typo-thumb will make a selector list fail. They, and weird `id` selectors that aren’t correctly escaped, will invalidate the entire selector list.

Because the entire selector list and associate code block are ignored if a selector list contains an invalid selector, old code bases have style blocks that are repeated multiple times.

For example, the pseudo-element selector targeting scrollbar and range thumbs are unique to each browser engine; with no browser supporting all of the following:

body,
::-moz-range-thumb,
::-ms-thumb,
::-webkit-scrollbar-thumb
{
border: 5px solid red !important;
}

With the above code, neither the <body> nor any scrollbar or range thumb will have a red border in any browser. Because at least one of the prefixed pseudo-elements will be invalid in every browser, the entire block is ignored, and the style block is not applied to anything, not even the <body>. Because of this, developers have been separating out each prefixed pseudo-element, leading to code similar to the following because normal selector lists are not forgiving.

body { border: 5px solid red !important; }

::-moz-range-thumb{ border: 5px solid red !important; }

::-ms-thumb{ border: 5px solid red !important; }

::-webkit-scrollbar-thumb { border: 5px solid red !important; }

Forgiving selector list

In a forgiving selector list, each selector is parsed individually. Invalid selectors within the list are simply ignored. The selector lists for :is(), and :where() are forgiving. 

The above could be written as:

body,
:is(
::-moz-range-thumb,
::-ms-thumb,
::-webkit-scrollbar-thumb
)
{
border: 5px solid red !important;
}

This is less verbose while having the same specificity. 

Until :has() is supported in all browsers, when including :has() with other selectors in a selector list, enclose the selector within an :is() or :where().

h3,
:is(h2:has( + h3))
{
margin-bottom: 3rem;
}

Specificity

The :has() selector and the other functional pseudo-classes themselves don’t add any specificity weight to the selector. Rather, except in the case of the specificity-adjustment :where() pseudo-class, the argument adds the weight. The specificity weights of the`:not()`, `:has()`, and `:is()` pseudo-classes are the weights of the selector in the selector list with the greatest specificity.

Specificity of :is()

Given :is(#fakeId#fakeId#fakeId, body), even though body is a type selector, which has low specificity, the specificity weights of the associate property values is the weight of three id selectors. It matches <body> with a specificity of 3-0-0. The specificity that :not(), :has(), and :is() contribute to the specificity algorithm is the specificity of the selector in the parameter that has the greatest weight.

Specificity of :where()

The `:where()` pseudo-class is different; it adds no weight to the specificity equation.  :where(#fakeId#fakeId#fakeId, body) matches <body> with a specificity of 0-0-0.

Relative selector

Each selector within the :has() pseudo-class selector list is relational. Relational selectors are selectors that represent elements relative to one another.

In the case of the :has() relational selector, the relative selector is in relation to the anchor element, which is the element on which the :has() is applied. In the case of  h2:has(+h3), the anchor is the h2

A relative selector is one that starts with a combinator, whether explicit or implied. If there is no explicit combinator, like >, ~, or +, the descendant combinator (a space) is implied.

Compare the following selectors:

Back to our code example:

<main>
<h1 data-function=”mainheader”>
<h2 data-function=”byline”>
<p>
<h2 data-function=”subheader”>
<p>
<h3 data-function=”subheader”>
<p>
<h2 data-function=”subheader”>
<h3 data-function=”byline”>
<p>
</main>

The selector main:has(h2 + h3), reads “every main that has a descendant h2 which has an h3 as an adjacent sibling”. Associated styles will apply to main, the anchor, if there is a descendant <h2> immediately followed by an <h3>. This is an ancestor match. In our code example, the last <h2> has an <h3> as an adjacent sibling, so this selector selects the ancestor <main>.

For an actual parent selector, (not just an ancestor selector) we use the child combinator (>)main:has(> h2 + h3) will match as well, as long as the <h2> is a direct child of <main>, which it is. 

The parent and ancestor examples had main as the anchor. If we allow the relationship to be implied, we may not get what we expect. If we include h2:has(h3) nothing is matched in this scenario, as h3 is not a descendant of the h2

The reason :has() is called a relational selector rather than a parent or ancestor selector is because it can be used for more than just ancestral reasons. To select the h1 only if it is immediately followed by an h2, we write h1:has(+ h2). The + is the adjacent sibling combinator. * + h2 is a relational selector. 

There is also a general sibling combinator (~). [data-function="byline"]:has(~ [data-function="byline"]), which reads “match any element with the byline function which has a sibling with the byline function”. This will match all the sibling elements with the byline function except the last, regardless of the element type. The last one isn’t matched because it doesn’t have a sibling of that class that comes after it.

Before :has() was available, while we could select a previous sibling of the same type, selecting a previous sibling element of a different type was not possible.

Logical operations

The :has() relational selector can be used to check if one of the multiple features is true or if all the features are true. 

When we include multiple selectors in the relative selector list, we can match the anchor element if any of the relational selectors are true. The following matches main if there are any bylines (a header immediately followed by a next-level header):

main:has( > h1 + h2, > h2 + h3, > h3 + h4) p {}

This reads “style all paragraphs in main if the main has a child h1 immediately followed by an h2, a child h2 immediately followed by an h3, or a child h3 immediately followed by an h4. Including multiple selectors in the list against an anchor is an OR statement.

You can also string multiple relational selectors together to create an AND statement.

main:has( > h1 + h2):has( > h2 + h3) p {}

This reads “style all paragraphs in main if the main has a child h1 immediately followed by an h2 AND a child h2 immediately followed by an h3." 

This codepen includes styles for all these examples

See the CodePen.

Usefulness

The :has() selector is more useful than you likely ever imagined. 

[type=”checkbox”]:required:not(:checked) + label {
/* styles to applied to the label of all required checkboxes
that aren’t checked yet */

}

With the adjacent sibling combinator, we have been able to style checkbox and radio button labels which generally come after the form control. Now, with :has(), we can style labels that come before their associated form control.

p:has(:required:invalid) label,
*:has( > label + :required:invalid) > label
{
color: red;
}

The above provides two selectors that will turn a label red if the form control is required but not valid, either because the value is invalid or if there is no value entered. The first selector selects the <label> inside a <p> if the paragraph contains a form control that is required but not currently valid. When the form control becomes valid, the selector will no longer match, and the label will no longer be red (unless some other CSS made it red). 

The second line is more robust. You may not know exactly how the list of form controls is constructed, but that’s OK. You don’t need JavaScript to toggle class names! The second selector styles the label child of any element when a required but invalid form control is the adjacent sibling of that label.

See the CodePen.

The next time you think “let me just add a class to that" or if you ever consider needing to add an inline style, consider whether the element can be targeted with :has()

For example, how do you style all the cells in a <table> column without adding a class to each <td>? While  <col>, the table column element, supports a few styles, there’s no way to style the contents of a cell based on it, or is there?  With :has(), you can style something in a <tbody> or <tfoot> based on whether a table has a specific attribute or attribute value in the <caption>, <colgroup>, or <thead>. (And really learning HTML helps.)

table:has(col.right:last-child) tbody tr > :last-child {
text-align: right;
}

If the last <col> in a table has the right class, then the last child of each row in the table body should be aligned right. In this case, we are styling something in one section of a table based on the presence of a class in a different section of the table. This was not possible before.

See the CodePen.

This example enabled adding the class of right or center to the last six col elements in a colgroup, styling the last td or th in each row in the tbody based on the class set of the associated but not ancestral col. For years, developers have been complaining about the limited styles that can be set on col elements; :has() helps alleviate table styling headaches.

Avoiding failure

For the time being, if you are using :has() within a selector list, stick the :has() within a :where() or :is() to enable Firefox to ignore the :has() until it is supported by default (it is currently behind a flag).

Note that nesting a :has() inside another :has() is not permitted. The :has() pseudo-class is not supported on pseudo-elements either. For example, ::first-line:has(a[href]) will not target a first line containing a link; it is invalid. Similarly, pseudo-elements are not supported within the relative selector list. The specification has allowed for supporting pseudo-elements in the future but has not defined the support of any pseudo-elements as of yet

Estelle Weyl

Estelle Weyl

Estelle has been building websites since 1999, and documenting the process for other front-end engineers since 2007; reading web specifications so you don't have to. She is an open source maintainer, technical writer, teacher, and developer advocate, speaking at and organizing conferences, including #PerfMattersConf.

Estelle selected Open Web Docs for an honorary donation of $50 which has been matched by Netlify

Open Web Docs

Open Web Docs supports web platform documentation for the benefit of web developers & designers worldwide.