Command Palette

Search for a command to run...

Hectal
PHASE 11Advanced Baseline 2023+ ~27 min· topic 2 of 8

Topic 11.2

:has() in Practice

In one line

:has() matches an element when something inside it, or after it, matches the selector you give; that lets CSS style a form from the state of its inputs, a parent from its children, and a list from how many items it has, all without JavaScript.

Think of it like this

A bag check at a concert. The guard doesn't style the people; she looks inside each bag and puts a sticker on the bag's owner if the bag has a water bottle. :has() works the same way: it looks inside (or next to) an element and marks the element itself when it finds a match. For years CSS could only look down the tree from a parent; :has() lets a rule depend on what is inside.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

`:has()`
A pseudo-class that matches an element if the selector inside the brackets finds a match relative to it.
Relative selector
A selector read starting from another element, like > img (a direct child) or + p (the next sibling).
Subject
The element a selector actually styles. In .card:has(img), the subject is the card, not the image.
Sibling
An element with the same parent. + means the very next sibling; ~ means any later sibling.
`:invalid`
Matches a form field whose value breaks its rules, for example an empty required field.
`:user-invalid`
Like :invalid, but only after the user has changed the field or tried to submit, so errors don't show on a fresh form.
Quantity query
A selector that styles a list based on how many items it has, using :nth-child() inside :has().
Unforgiving list
A selector list where one invalid entry makes the whole thing invalid.

Step by step

01Read :has() as "that has"

Read .field:has(input:focus) aloud: "a .field that has a focused input inside it". The rule styles the .field. This is how you highlight a whole form row while its input is focused.

style.csswhole filecss
.field:has(input:focus-visible) {
  background: #eff6ff;
}

02React to form states

Mark required fields, show errors only after the user has typed, and dim the submit button while any field is invalid or the terms box is unticked. No JavaScript is needed for any of these looks. (The server must still validate: CSS only changes what people see.)

style.csswhole filecss
.field:has(:required) label::after { content: " *"; color: #dc2626; }
.field:has(input:user-invalid) input { border-color: #dc2626; }
form:has(input:invalid, #terms:not(:checked)) button {
  opacity: 0.5;
}

03Style a parent by its children

A card with an image becomes a two-column grid; a card without one gets a dashed border. The HTML needs no extra classes.

style.csswhole filecss
.card:has(> img) {
  display: grid;
  grid-template-columns: 64px 1fr;
  gap: 12px;
}
.card:not(:has(img)) { border-style: dashed; }

04Count items with a quantity query

If a 4th item exists, the list has at least 4. If the 3rd item is also the last, the list has exactly 3.

style.csswhole filecss
ul:has(> li:nth-child(4)) { columns: 2; }            /* 4 or more */
ul:has(> li:nth-child(3):last-child) { color: teal; } /* exactly 3 */
Count items with a quantity querydiagram
Rendering diagram…

05Look sideways at siblings

:has(+ x) checks the next sibling, which gives CSS a "previous sibling" selector at last: h2:has(+ .subtitle) removes the heading's bottom margin only when a subtitle follows it.

style.csswhole filecss
h2:has(+ .subtitle) { margin-bottom: 0; }

06Plan for support and speed

Every current browser supports :has() (Baseline 2023). For older ones, wrap enhancements in @supports selector(:has(a)) { ... } so the base styles still work. Keep the subject specific (.field:has(...) rather than *:has(...)), so the browser has fewer elements to recheck when something changes.

Try it yourself

  1. 1

    Make the error visible

    In the first example, type asha (no @) into the email box and then click outside it. Predict what changes and why it didn't happen before you typed. (:user-invalid waits for the user.)

  2. 2

    Count differently

    In the second example, change the quantity query so two columns start at 6 items. Predict which list loses its two columns, then run it.

  3. 3

    Highlight the chosen option

    Add three radio buttons wrapped in labels to the third example and write label:has(input:checked) { font-weight: bold; }. Predict what happens to the old label when you choose another option.

Code & diagrams

Form states without JavaScript New tab

Only the field that has a `required` input gets the star. The button stays dim while the email is empty or the box is unticked. Type a bad email and click elsewhere to see `:user-invalid` turn the border red.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

email label marker: " *"
age label marker: none
button opacity at start: 0.5
button opacity when valid: 1
Parent styling and quantity queries New tab

Delete the image from the first card and run again: it stops being a grid and gets the dashed border. Add a 4th item to the last list and it loses teal and gains two columns.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

card 1 display: grid
card 2 border-style: dashed
2 items, columns: auto
5 items, columns: 2
exactly 3, colour: rgb(0, 128, 128)
A checkbox that changes the whole page New tab

The checkbox is not an ancestor of the items, but `.page:has(#compact:checked)` turns the page into the subject, and the descendant `.item` follows. Click the checkbox to toggle it yourself.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

normal padding-top: 14px
compact padding-top: 4px

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Put a pseudo-element inside :has()

In the second example, add .card:has(p::before) { outline: 3px solid red; }.

terminal
$ Look at the cards
── what you'll see ──
Nothing changes, and DevTools doesn't list the rule at all: the browser dropped it as invalid.

Break #2

Expect :has() to style the inner element

Write .field:has(input:invalid) { border-color: red; } and expect the input's border to turn red.

terminal
$ Look at the input
── what you'll see ──
The input's border stays grey; the `.field` box gets the red border colour instead (only visible if it has a border style).

Myth vs fact

Myth

:has() is only a parent selector.

Fact

It matches based on any relative selector: descendants (:has(img)), direct children (:has(> img)) and later siblings (:has(+ p), :has(~ p)). That makes it a previous-sibling selector too.

Myth

:has() is too slow to use.

Fact

Browsers optimise it heavily and it is fine for normal use. Cost grows when the subject is very broad (*:has(...) or body:has(...) that changes often), so anchor it to specific elements.

Myth

Styling invalid fields with :invalid is good UX.

Fact

:invalid matches from the first paint, so an empty required field looks wrong before the user typed anything. Use :user-invalid for error looks.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    :has() shipped in Safari 15.4 (2022), Chrome 105 (2022) and Firefox 121 (December 2023), making it Baseline 2023. For browsers without it, @supports selector(:has(a)) lets you wrap the enhancement.

  • ▸

    Invalidation is the hard part: when a DOM change happens deep inside, the browser must find which ancestors' :has() results changed. Engines keep extra bookkeeping (Chromium and WebKit track which elements could be affected by a :has() argument). Keeping the argument simple (> img, :checked) and the subject narrow keeps that work small.

  • ▸

    :has() was originally specified with a forgiving list, but in late 2022 it was changed to unforgiving to avoid ambiguity with jQuery's own :has(). Wrap uncertain parts in :is() if you need forgiving behaviour: :has(:is(.a, :unknown-thing)).

  • ▸

    :user-invalid reached all major browsers in 2023. Error text tied to it should still be announced to screen readers: link it to the input with aria-describedby, as the HTML course's accessibility phase (/html, Phase 7) explains.

Remember this

  1. 1

    The syntax is A:has(B): "an A that has a B inside it". B is a relative selector: it is read starting from A. .card:has(img) is a card with an image anywhere inside; .card:has(> img) is a card whose direct child is an image; h2:has(+ p) is an h2 immediately followed by a paragraph (a sibling, not a descendant). The element that gets styled is always A, called the subject of the selector. (Phase 1 introduced :has() with :is(), :not() and :where(); this topic is about using it well.)

  2. 2

    Form states are the most common real use. Inputs already have state pseudo-classes: :checked, :disabled, :focus-visible, :invalid (fails its rules, like an empty required field), and :user-invalid (invalid after the user has interacted with it, so fresh forms don't shout errors). With :has(), the wrapper can react: .field:has(input:user-invalid) gives the whole field a red border and shows its error text, .field:has(:required) label::after { content: " *" } marks required labels, and form:has(:invalid) button[type=submit] dims the submit button until everything is valid.

  3. 3

    Parent styling means styling a container based on what it contains. .card:has(img) switches a card to a two-column layout when there's a picture; .card:not(:has(img)) styles cards without one; li:has(> ul) marks menu items that open a sub-menu; label:has(input:checked) highlights the chosen option in a group of radio buttons. You no longer need an extra class like card--with-image that someone must remember to add.

  4. 4

    Quantity queries style a list by how many items it has. li:nth-child(4) exists only when there are at least 4 items, so ul:has(> li:nth-child(4)) means "a list with 4 or more items". Add :last-child to get an exact count: ul:has(> li:nth-child(3):last-child) is a list with exactly 3. ul:not(:has(> li:nth-child(2))) is a list with at most one item. Use them to switch a long list to two columns or a short one to a single row.

  5. 5

    :has() can also act globally: html:has(dialog[open]) stops the page scrolling while a dialog is open (overflow: hidden), and body:has(#compact:checked) makes a checkbox switch the whole page into a compact mode. It's powerful, but it puts page-wide state in CSS, so keep these few and obvious.

  6. 6

    Rules to remember. The specificity of :has() is the specificity of its most specific argument, like :is(). You can't put :has() inside another :has(), and you can't put pseudo-elements like ::before inside it. Its argument list is unforgiving: if one selector in the list is invalid, the whole :has() is invalid. To test for support, use @supports selector(:has(a)).

Explain it without notes

01

What does :has() do, and which element does a selector like .card:has(img) style?

02

Show how :has() can style a form based on its inputs' state, and why :user-invalid is better than :invalid for error styles.

03

Explain a quantity query that matches lists with at least 4 items and one that matches exactly 3 items.

04

What are the restrictions and the specificity rules of :has()?

Practice

01

Write CSS that gives a <figure> a grey background only when it contains a <figcaption>.

02

Write CSS so a .plan label gets a 2px indigo border when the radio button inside it is checked.

03

Write a quantity query that makes a .tags list display as a single flex row when it has 3 or fewer items.

Trade-offs

  • ↔

    :has() removes JavaScript and helper classes for many states, but it moves logic into selectors that are harder to search for and debug than a class name added by a script.

  • ↔

    Global switches like html:has(dialog[open]) are elegant, but every such rule makes the browser recheck a large subject when things change; keep them few.

Done when you can

  • I can read A:has(B) and name the subject.

  • I can style a form wrapper from its inputs' states with :has().

  • I know when to use :user-invalid instead of :invalid.

  • I can write quantity queries for "at least n" and "exactly n".

  • I can use :has(+ x) as a previous-sibling selector.

  • I know the restrictions: no nested :has(), no pseudo-elements, unforgiving lists.