The CSS :has() selector

By

The CSS parent selector styles an element from what is inside it. Forms, cards, previous siblings, quantity queries, and when JavaScript is still better.

~~~

For years CSS could only look down the tree. You styled a child. You styled a next sibling. You could not style a parent because of what was inside it.

So we added classes with JavaScript. is-invalid on the field wrapper. has-image on the card. is-checked on the label. The HTML grew flags that only existed to please CSS.

:has() is the parent selector. You keep the meaning in the markup. The browser looks inside, or next to, an element, and styles that element.

.field:has(input:user-invalid) {
  border-color: red;
}

The wrapper turns red because the input inside it failed validation. No extra class. No script.

I use it anywhere I used to toggle a class on a parent. I still use JavaScript when the page has to do something, not just look different.

How :has() thinks

The selector is anchor:has(relative).

The anchor is the element you style. The part inside :has() is a relative selector, starting from that anchor. If anything in there matches, the anchor matches.

article:has(img) {
  padding: 0;
}

“Select article if it contains an img.” The style goes on the article, not on the image.

A child combinator keeps the search shallow:

article:has(> img) {
  padding: 0;
}

Now the image must be a direct child. Faster to think about. Faster for the browser too, on a big tree.

:has() also looks forward at siblings. That is the other trick people wanted for years: style an element based on what comes after it.

h1:has(+ h2) {
  margin-bottom: 0.25rem;
}

If an h1 is immediately followed by an h2, tighten the gap. You are styling the heading that came first. Old CSS could not do that. The next-sibling combinator only styles the second element.

You can try selectors like this in the CSS selector tester if you want to see a match light up on a sample tree.

Highlight a form field that failed

A label wrapping an input is the example I reach for first.

<form class="signup">
  <label class="field">
    Email
    <input type="email" name="email" required>
  </label>
  <button type="submit">Subscribe</button>
</form>
.field {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  border: 1px solid #ccc;
  padding: 0.75rem;
}

.field:has(input:user-invalid) {
  border-color: red;
}

.field:has(input:user-valid) {
  border-color: green;
}

:invalid is true as soon as the page loads, because an empty required field is invalid. That paints every required field red before the reader types. :user-invalid waits until the person interacted with the field, or tried to submit.

This sits on top of normal HTML form validation. The browser already knows the field is wrong. You are only dressing the wrapper.

You can lift the same idea to the whole form:

.signup:has(:user-invalid) {
  box-shadow: 0 0 0 1px red;
}

I would keep that light. A red ring on the form is enough. Do not hide the submit button just because one field is empty. The reader still needs a way to try.

Cards that change when they contain something

A card with an image wants different padding than a text-only card.

<article class="card">
  <img src="/images/css-grid.jpg" alt="">
  <div class="card-body">
    <h2>CSS Grid</h2>
    <p>Two-dimensional layout without hacks.</p>
  </div>
</article>

<article class="card">
  <div class="card-body">
    <h2>CSS Units</h2>
    <p>px, rem, em, and when to use each.</p>
  </div>
</article>
.card {
  padding: 1rem;
}

.card:has(img) {
  padding: 0;
}

.card:has(img) .card-body {
  padding: 1rem;
}

Cards without an image keep their padding. Cards with an image go edge to edge, and the body gets the inset.

The same pattern works for a featured flag in the HTML:

section:has(.featured) {
  border: 2px solid blue;
}

You style the section because one article inside it is featured. The featured article can keep its own styles. The parent reacts.

This is the DOM half of responsive cards. Container queries are the space half. :has(img) asks what is inside. @container asks how wide the slot is. A wide card with an image is a different layout from a narrow text card.

Style a label from a checkbox

:has() works with other pseudo-classes.

<label class="option">
  <input type="checkbox" name="topics" value="css">
  CSS
</label>
.option {
  display: flex;
  gap: 0.5rem;
  padding: 0.5rem 0.75rem;
}

.option:has(input:checked) {
  background: #e8f5e9;
  font-weight: bold;
}

.option:has(input:disabled) {
  opacity: 0.5;
}

The label reacts to the control inside it. You do not need the old input + span trick, and you do not need the checkbox to be a sibling of the text.

Radio groups work the same way. So do select elements:

.field:has(select:required:invalid) {
  border-color: red;
}

Style the previous sibling

This is the one that still feels like a magic trick.

h1:has(+ p) {
  margin-bottom: 0.5rem;
}

figure:has(figcaption) img {
  margin-bottom: 0.25rem;
}

li:has(+ li) {
  border-bottom: 1px solid #ddd;
}

h1:has(+ p) styles the heading when a paragraph follows it. li:has(+ li) styles every item that is not the last one, because a next li exists.

You can also look further:

h2:has(~ h2) {
  /* this h2 is not the last h2 in the block */
}

+ is the next sibling. ~ is any later sibling.

I use this for heading spacing more than anything else. Two headings in a row should sit closer than a heading followed by a paragraph. That used to be a special class or a :has()-less compromise on the second heading.

Quantity queries

:has() plus :nth-child lets you style a list based on how many items it has.

.tags:has(> * :nth-child(2)) {
  /* at least 2 tags */
}

.tags:has(> * :nth-child(5)) {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

.gallery:has(> img:only-child) {
  max-width: 28rem;
}

:has(> * :nth-child(5)) is true when a fifth child exists, so the list has at least five items. A short tag list can stay inline. A long one wraps.

A gallery with one image should not stretch like a five-image grid. :only-child says that without counting in JavaScript.

AND versus OR

A comma inside :has() is OR. Chaining :has() is AND.

body:has(video, audio) {
  /* the page has a video OR an audio player */
}

body:has(video):has(audio) {
  /* the page has both */
}

.card:has(img):has(figcaption) {
  /* image and caption */
}

I almost never put :has() on body. The example is only here to show the logic. Anchor it to a smaller element. More on that in a minute.

:is() keeps long lists readable:

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

Any of those headings, followed by a slightly smaller heading, gets a tighter margin.

Combining with :not()

Exclude the cases you do not want:

.nav:has(a.active):not(:has(.dropdown)) {
  border-bottom: 2px solid blue;
}

.card:not(:has(img)) {
  background: #f6f6f6;
}

The first rule styles a nav that has an active link, but only when there is no dropdown inside. The second is often clearer than inventing a card--text-only class.

.card:not(:has(img)) and .card:has(img) are a good pair. One path for text cards, one path for image cards. Nothing in between.

Specificity

:has() does not add its own specificity. It takes the specificity of the most specific selector in its argument, the same way :is() and :not() do.

.card {
  /* 0, 1, 0 */
}

.card:has(img) {
  /* 0, 1, 1  (.card + img) */
}

.card:has(.hero-image) {
  /* 0, 2, 0  (.card + .hero-image) */
}

.card:has(.hero-image) beats .card:has(img). If a rule “does not apply,” check whether a more specific :has() won. The CSS specificity guide is the longer version of this.

Because the argument counts, a sloppy :has() can become a specificity hammer:

.card:has(#intro img.hero) {
  /* 1, 2, 1  — this will bully a lot of other rules */
}

Keep the inside simple. A class or an element is enough.

What :has() cannot do

You cannot nest :has() inside :has(). This is invalid:

article:has(div:has(img)) {
  /* no */
}

Write the descendant you mean:

article:has(img) {
  /* yes */
}

Pseudo-elements are not allowed inside :has(), and :has() cannot hang off a pseudo-element.

article:has(::before) {
  /* no */
}

article::before:has(img) {
  /* no */
}

The reason is cycles. ::before exists because of styles. If styles could ask whether ::before exists, the browser would chase its own tail.

If a browser does not support :has(), the whole selector is dropped. The rest of the rule does not apply. Wrap a fallback with a feature query when the layout would break without it:

.card {
  padding: 1rem;
}

@supports selector(:has(*)) {
  .card:has(img) {
    padding: 0;
  }
}

For current Chrome, Firefox, and Safari you do not need that. I add @supports when the unstyled state would be wrong, not just less fancy.

Do not put it on the whole page

:has() is not free. On every DOM change, the browser has to ask: does this still match?

If the anchor is body, :root, or *, every mutation can walk a huge tree.

/* don't */
body:has(.sidebar-open) {
  overflow: hidden;
}

*:has(.item) {
  color: red;
}

Anchor to the element that actually changes:

.layout:has(> .sidebar-open) {
  overflow: hidden;
}

.gallery:has(> img[data-loaded='false']) {
  opacity: 0.7;
}

Prefer > and + inside :has() when you can. .card:has(> img) checks one level. .card:has(img) may walk everything inside the card.

Use :has() for local UI. A field, a card, a nav, a list. If you need a page-wide mode — “the mobile menu is open, lock the body” — a single class on body from a click handler is still the honest solution. One class. No tree walk on every keystroke.

How I would use this

I would use :has() on forms first.

The newsletter form on /access and the waiting-list forms on the course pages are labels plus inputs. A red border on .field when the email is :user-invalid is exactly the old is-invalid class, without the class.

I would use it on cards that optionally have an image or a “featured” mark. This blog’s posts are mostly text. When a card does include an image, I want the padding to collapse without a has-image modifier in the HTML.

I would use h1:has(+ h2) and h2:has(+ h3) in long articles. Heading pairs should sit tighter than a heading plus a paragraph. That is a spacing bug I have fixed with extra classes too many times.

I would not use it to replace application state. Opening a dialog, submitting a form, filtering a list: those are still JavaScript. A dialog element and a click handler do that job. :has() can style the page around an open dialog if you want, but it should not be the thing that opens it.

I would not write body:has(...) to theme the whole site from a checkbox in the footer. It works in a demo. It is a performance footgun on a real page.

The free CSS course covers selectors and pseudo-classes before you get here. :has() is easier once :hover, :checked, and :not() already feel normal.

Browser support

:has() has been in all modern browsers since December 2023. It is safe in production now.

Safari was last. If you still see a screenshot from 2022 saying “Chrome only,” that page is outdated.

My advice: use :has() when the only reason you opened JavaScript was to add a class on a parent. Keep the state in the HTML. Let CSS read it. Reach for a script when the user action has to change more than styles.

Tagged: CSS · All topics
~~~

Related posts about css: