:has() — the "parent selector" CSS waited decades for
group-* reaches DOWN (ancestor → descendant).
peer-* reaches FORWARD (older sibling → younger sibling).
Neither can go UPWARD — you cannot style a parent based on what its
child contains. :has() closes that hole:
:has(X) matches an element that contains a descendant
matching X. Tailwind v4 ships it as three variants that map to
three selector shapes.
| variant | compiles to | selector shape | I style myself if… |
|---|---|---|---|
has-[X] |
.el:has(X) |
:has() on the element itself | I contain a descendant matching X |
group-has-[X] |
.group:has(X) .group-has-\[X\] |
:has() on the group, descendant combinator | my .group ancestor contains X |
peer-has-[X] |
.peer:has(X) ~ .peer-has-\[X\] |
:has() on the peer, general sibling combinator | my earlier .peer sibling contains X |
Why a third variant family? Because group-* and
peer-* can only react to a state of the group/peer element
ITSELF (:hover, :checked…). They cannot ask
"does the group CONTAIN a checked box anywhere inside it?".
group-has-[:checked] can.
1 · form section turns red when it holds an invalid input
The <section> itself carries
has-[input:invalid]:border-red-500. The moment any
<input> inside it becomes :invalid, the
WHOLE section re-styles — no JS, no aria-invalid toggling.
This is the classic "field-level error → section-level affordance" pattern.
Section reads has-[input:invalid] → border + bg flip red
while the email is invalid.
section border-color: —
2 · card re-layouts when it contains a checked checkbox
The card carries has-[:checked]:flex-row alongside a default
flex-col. Tick the box and the card flips from a vertical
stack to a horizontal row, plus its border + bg go green. This is a
layout decision driven by child state — impossible before
:has().
Enable experimental feature
flex-direction: column → row when this box is checked.
card flex-direction: — · background: —
3 · list row highlights when it holds a focused input
Each <li> carries
has-[input:focus]:border-cyan-500. Click into a field (or Tab
to it) and the entire row lights up — the affordance travels up to
the parent container, not just the field. Try it:
row A background: —
group-has-* vs peer-has-* — the same :has(), different reach
The group-has-* / peer-has-* variants apply the
SAME :has() test, but on a different element (the
marked .group ancestor or .peer sibling). They
matter when the reacting element is NOT the container itself.
group-has-[:checked]
Paragraph is a descendant of .group — turns green when
the group contains a checked box.
peer-has-[:checked]
Paragraph is a SIBLING of .peer — turns green when the
peer contains a checked radio (a container peer, not a single input).
The key difference: group-checked: only fires if the
group element itself is checked (impossible for a <div>);
group-has-[:checked] fires if the group contains ANY checked
descendant. Same story for peer.
intent → pattern
| intent | pattern | why |
|---|---|---|
| section reacts to an invalid field inside it | <section class="has-[input:invalid]:border-red-500"> |
style the container from a descendant's validity — no JS |
| card reacts to a checked box anywhere inside | has-[:checked]:bg-green-900 |
:checked alone can't be read by an ancestor without :has() |
| row highlights when a child has focus | has-[input:focus]:bg-cyan-900 |
parent-level affordance for keyboard users — pairs with focus-within |
| react to a deeper / arbitrary child state | has-[>img]:block · has-[.error]:border-red-500 |
any selector goes in the brackets — classes, attrs, combinators |
| descendant reacts if its GROUP contains state | group-has-[:checked]:text-green-300 |
the :has() runs on the .group, not on the reacting element |
| sibling reacts if its PEER contains state | peer-has-[:checked]:block |
peer is a CONTAINER (fieldset/div) — peer-checked could not do this |
| empty-state for a list | has-[:not(:scope>*)]:opacity-50 |
container has no direct children → dim it (advanced) |