Style Parent Elements from Their Children with Tailwind v4's has-* Variant

The Tailwind has variant compiles to CSS :has() so a parent reacts to its children. Use has-[:checked], group-has-*, peer-has-*, and arbitrary selectors.

Steven Richardson
Steven Richardson
· 6 min read

For years, styling a container based on something inside it meant reaching for JavaScript: listen for a change event, toggle a class on the parent, keep the two in sync. Tailwind v4 exposes CSS :has() as a first-class variant, so the parent can react to its own descendants with nothing but utility classes.

What the has-* variant actually does#

The Tailwind has variant compiles straight to the CSS :has() pseudo-class. When you write has-[:checked] on an element, Tailwind generates a selector that applies the utility to that element when it contains a checked descendant. The parent is styled from the child's state — the exact inversion of how CSS normally cascades.

<!-- Applies the ring to the label itself when the input inside is focused -->
<label class="rounded-lg border p-4 has-[:focus]:ring-2 has-[:focus]:ring-blue-500">
  <span class="text-sm font-medium">Email</span>
  <input type="email" class="mt-1 block w-full" />
</label>

That single has-[:focus]:ring-2 replaces the usual pattern of a focus listener plus a state class. This is the descendant-based counterpart to Tailwind's not-* variant for negation styling — one styles based on what an element contains, the other based on what it isn't.

Style a card when its checkbox is checked#

The example that sells has-* immediately is a selectable card. Put the checkbox inside the card, and let the card style itself when the box is checked:

<label class="block cursor-pointer rounded-xl border-2 border-gray-200 p-5
              has-[:checked]:border-blue-600 has-[:checked]:bg-blue-50">
  <div class="flex items-center justify-between">
    <span class="font-semibold">Pro plan</span>
    <input type="checkbox" name="plan" class="accent-blue-600" />
  </div>
  <p class="mt-2 text-sm text-gray-500">Everything in Starter, plus priority support.</p>
</label>

No Alpine, no wire:model round-trip just to change a border colour. The border and background respond the moment the input's state changes, entirely in CSS. This pairs well with the layout work in building aligned card grids with subgrid when you have a row of selectable options that need to line up.

The same idea covers validation styling. A form row can flag itself when the input inside it is invalid, which slots neatly alongside the user-valid and user-invalid form state utilities:

<div class="rounded-md p-3 has-[:user-invalid]:bg-red-50">
  <label class="text-sm">Card number</label>
  <input required inputmode="numeric" class="mt-1 block w-full" />
</div>

group-has-* and peer-has-* for ancestors and siblings#

Plain has-* only reaches from an element to its own descendants. When the element you want to style isn't the direct parent of the state you're reacting to, reach for group-has-* and peer-has-*.

group-has-* lets any element inside a marked group respond to a descendant elsewhere in that group. Mark the common ancestor with group, then style a sibling branch based on state in another branch:

<div class="group grid grid-cols-[1fr_auto] gap-4">
  <div>
    <input type="search" placeholder="Search orders" class="w-full" />
  </div>
  <!-- The clear button only appears once the search below has focus -->
  <button class="opacity-0 group-has-[:focus]:opacity-100">Clear</button>
</div>

peer-has-* does the same across siblings marked with peer. Both compose with the component-scoping patterns you'd already use for responsive components with container queries — the :has() reaction is orthogonal to the breakpoint the component is rendered at.

Arbitrary selectors inside has-[...]#

The brackets aren't limited to pseudo-classes. Anything valid inside CSS :has() works, including attribute and element selectors. That makes has-* useful for reacting to structure, not just interaction state:

<!-- Add a top border only when the list actually contains an <li> -->
<ul class="has-[li]:border-t has-[li]:pt-3">
  @foreach ($errors->all() as $error)
    <li class="text-sm text-red-600">{{ $error }}</li>
  @endforeach
</ul>

<!-- React to a data attribute set elsewhere in the tree -->
<div class="group">
  <section class="hidden group-has-[[data-open]]:block"></section>
  <button data-open>Show details</button>
</div>

The selector between the brackets is passed through to :has() verbatim, so has-[img], has-[[aria-current]], and has-[:nth-child(3)] all generate the selector you'd expect.

Gotchas and Edge Cases#

:has() is not a magic wand. A few things bite in production.

The variant reacts to any matching descendant at any depth, not just direct children. has-[:checked] fires if a checkbox three levels down is checked, which is usually what you want — but if you meant only direct children, scope it with a child combinator: has-[>:checked].

Specificity comes from the argument, not the pseudo-class. :has(.foo) carries the specificity of .foo, so a heavier selector inside the brackets can quietly win a cascade fight you didn't expect. Keep the arguments as simple as the reaction allows.

Performance is worth a thought on very large trees. Browsers optimise :has() well now, but a broad selector like has-[div] evaluated against a page with thousands of nodes does more work than a targeted has-[:checked]. Reach for specific, state-based selectors over structural ones where you can.

And there's no true fallback. :has() is Baseline — Chrome 105+, Safari 15.4+, Firefox 121+ — so any current browser handles it, but a browser without support simply skips the rule rather than degrading gracefully. If you must support something ancient, keep the :has()-driven styling to progressive enhancement rather than load-bearing layout.

Wrapping Up#

Reach for has-* whenever you catch yourself about to add JavaScript purely to toggle a class on a parent — selectable cards, focus-within containers, validation rows, and conditional UI all collapse to a single utility. Start with plain has-[...], and step up to group-has-* or peer-has-* only when the element you're styling sits outside the subtree holding the state.

For the sibling technique that styles based on what an element isn't, see the not-* negation variant, and if you're leaning into modern CSS variants generally, the CSS-first dark mode and custom variant setup shows how to define your own on top of what Tailwind ships.

FAQ#

What does the has-* variant do in Tailwind?

It applies utilities to an element based on its descendants by compiling to the CSS :has() pseudo-class. Writing has-[:checked]:bg-blue-50 styles the element whenever it contains a checked input, letting a parent react to a child's state without any JavaScript.

How do I style a parent based on its child in Tailwind?

Put a has-[...] variant on the parent and describe the child state inside the brackets. For example, has-[:focus]:ring-2 on a label adds a ring whenever the input inside it is focused. If the element you want to style isn't the direct ancestor, use group-has-* on a marked group container instead.

What is the difference between has-* and group-has-*?

has-* styles the element it's written on when that element contains a matching descendant. group-has-* styles any element inside a container marked with group when another descendant of that same group matches — so it reaches across branches of the tree rather than only into an element's own subtree. Use peer-has-* for the sibling equivalent.

What browsers support Tailwind's has-* variant?

The underlying CSS :has() is Baseline, supported in Chrome 105+, Safari 15.4+, and Firefox 121+, which covers all current browsers. Older browsers ignore the rule rather than erroring, so treat :has()-driven styling as progressive enhancement if you still support legacy targets.

Can I use has-* with arbitrary selectors?

Yes. Anything valid inside CSS :has() works between the brackets, including attribute, element, and structural selectors — has-[img], has-[[data-open]], and has-[:nth-child(3)] all pass the selector through verbatim. This makes has-* useful for reacting to page structure, not just interaction state.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.