:has() variants

[check: …]
📖 Pair this live Tailwind demo with the companion guide (.md) — this page is the rendered ground truth. ↗ Builds on group & peer variants:has() is the missing piece group/peer could not reach: style a parent from a child's state.

: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.

variantcompiles toselector shapeI 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: columnrow 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]

Ship:

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

intentpatternwhy
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)
<!-- has-* : style the container from a child's state --> <section class="has-[input:invalid]:border-red-500"> <input type="email" required> </section> <!-- group-has-* : descendant reacts if its GROUP contains X --> <div class="group"> <input type="checkbox"> <p class="group-has-[:checked]:text-green-300">turns green when group has a check</p> </div> <!-- peer-has-* : sibling reacts if its PEER contains X --> <fieldset class="peer"> <input type="radio" name="s"> </fieldset> <p class="peer-has-[:checked]:text-green-300">turns green when peer has a check</p>