A Practical Guide to the CSS :has() Selector: Selecting Parents Without JavaScript

Cards that highlight when checked, forms that react as a whole to a bad input, star ratings that fill the stars before the one you hover. Things that used to need JavaScript, done with a single CSS :has() rule, with interactive examples.

CSS's long-awaited "parent selector"

For a long time, CSS selectors could only go downward. You could select a child inside a parent with .card img, but there was no way to select a parent based on its children, like "a card that has an image."

So code like this was everywhere:

// Add a class to the parent card when its checkbox is checked
checkbox.addEventListener("change", (e) => {
  e.target.closest(".card").classList.toggle("is-selected", e.target.checked);
});

With :has(), that becomes one line of CSS:

.card:has(input:checked) {
  border-color: orange;
}

A:has(B) selects "an A that contains a B." The style applies to the outer element that satisfies the condition in the parentheses.

The parentheses can describe siblings as well as descendants, so it effectively works as a "conditional selector."

SyntaxMeaning
A:has(B)An A with a B somewhere among its descendants
A:has(> B)An A with a B as a direct child
A:has(+ B)An A whose very next sibling is a B
A:has(~ B)An A with a B somewhere among its following siblings
A:not(:has(B))An A that does not contain a B

Let's try some practical examples. In each one, the top is the live result and the tabs below show the code.


Example 1. Cards that highlight when selected

You see this UI on pricing pages all the time: pick a radio button and the whole card is highlighted. There's no JavaScript at all.

It all comes down to the last rule: select the .plan that has an input:checked.


Example 2. The whole form reacts to a bad input

:has() pairs especially well with form state. In the example below, type anything into the email field and then click somewhere else.

The state of a single input changes the label, the form border, the hint, and the button all at once. This used to mean listening for input events, building state, and toggling classes on each element.

:user-invalid is a pseudo-class that only applies after the user has interacted with the field. With :invalid, every empty field is marked "wrong" the moment the page loads, so :user-invalid feels more natural for showing errors.


Example 3. Selecting previous siblings: a star rating

CSS's ~ and + combinators can only select following siblings. That's why it was hard to color "the hovered star and the stars before it" in a star rating. With :has(), you can select backwards too. Hover over the stars.

The result: stars fill up to the one you're hovering. You can also express it directly with sibling relationships:

/* An element whose very next sibling is hovered = the sibling right before the hovered one */
li:has(+ li:hover) {
  transform: scale(1.1);
}

That's how you build a macOS Dock-style effect where the icons on either side of the hovered one grow too.


Example 4. Changing layout based on item count

Conditions like "two columns once there are four or more items" are possible in pure CSS. Use the buttons below to add and remove items. (JavaScript only adds and removes items; CSS handles the layout switch.)

It uses :has() to ask whether li:nth-child(4) exists. This technique is known as a quantity query.


Example 5. Changing page-wide state

Put :has() on html or body and any element anywhere on the page can become the condition.

/* Lock background scrolling while a modal is open */
body:has(dialog[open]) {
  overflow: hidden;
}

/* Switch the whole page's colors when the dark mode checkbox is checked */
html:has(#dark-mode:checked) {
  color-scheme: dark;
  background: #111;
  color: #eee;
}

You can delete the code that sets document.body.style.overflow = "hidden" every time a modal opens and resets it when it closes.


Before you use it

Browser support

:has() is supported from Chrome/Edge 105, Safari 15.4, and Firefox 121, so it's been usable in all major browsers since late 2023. If you need to support older browsers, wrap it in @supports so it only applies where it works.

@supports selector(:has(a)) {
  .plan:has(input:checked) { border-color: #f59e0b; }
}

Things to watch out for

GotchaDetails
No :has() inside :has().a:has(.b:has(.c)) is invalid
No pseudo-elements inside the parentheses:has(::before) doesn't work
Keep the scope narrowSelectors like body:has(*:hover) make the browser keep re-checking the whole page, which costs performance. The narrower, the better
One bad selector drops the whole ruleIf any selector in the :has() list is invalid, the entire rule is ignored

Summary: why this is worth knowing

What you wantThe :has() answer
Highlight the card of a checked item.card:has(input:checked)
Make the whole form react to an errorform:has(input:user-invalid)
Highlight the label of the focused input.field:has(input:focus) label
Different layout only for cards with an image.card:has(img)
Select previous siblingsli:has(+ li:hover)
Layout based on item count.list:has(> li:nth-child(4))
Lock scrolling while a modal is openbody:has(dialog[open])

The real value of :has() is that it lets you delete JavaScript whose only job is adding and removing classes to reflect state. The state is already in the HTML: whether something is checked (:checked), whether an input is invalid (:user-invalid), whether the mouse is over it (:hover), whether it's open ([open]). :has() lets CSS read that state directly. Next time you reach for classList.toggle, check first whether CSS can do it.