EmpireUI
Get Pro
← Blog8 min read#tailwind#:has()#css selector

Tailwind :has() Selector: Parent Styling Without JavaScript

Learn how Tailwind v4's :has() support lets you style parent elements based on their children — no JavaScript, no state, just pure CSS selectors.

Code editor displaying CSS selector syntax on dark background

What :has() Actually Does (And Why You've Wanted It Forever)

For about fifteen years, CSS had no way to style a parent based on its children. You wanted to highlight a form row when its input was focused? JavaScript. You needed a card to change background when a checkbox inside it got checked? JavaScript. The cascade only ever flowed downward — from parent to child — and that was that.

:has() breaks that rule. It's a relational pseudo-class that matches an element *if* a given selector matches something inside it. Think of it as a 'parent selector' but more accurate — it's really a 'container selector.' It landed in all major browsers by late 2023, and Tailwind v4 ships first-class utility support for it out of the box. No plugins, no config gymnastics.

Honestly, it changes how you architect interactive UI. A whole category of patterns that used to require useState, refs, or event listeners can now be pure CSS. The code is shorter, the bundle is smaller, and there's one fewer moving part to debug at 2am.

Quick aside: :has() is not just about parents. You can use it to select an element that's *followed by* a sibling, or an element that *contains* a specific descendant at any depth. But the parent-styling use case is what most people reach for first.

Tailwind v4 Syntax: How to Write :has() Utilities

In Tailwind v4, arbitrary variants handle :has() with the bracket syntax you're already used to. The pattern is [&:has(selector)]:class-to-apply. That's it. You write the relational condition inside the brackets, then colon, then whatever utility you want applied to the parent.

<!-- Card that turns blue when its child checkbox is checked -->
<div class="p-4 rounded-xl border border-zinc-200 [&:has(input:checked)]:border-blue-500 [&:has(input:checked)]:bg-blue-50">
  <label class="flex items-center gap-3">
    <input type="checkbox" class="h-4 w-4" />
    <span class="text-sm font-medium">Enable feature</span>
  </label>
</div>

That renders a plain card. Check the box and the border goes blue and the background tints — zero JavaScript. The browser re-evaluates :has(input:checked) on every interaction automatically. Worth noting: this works even if the checkbox is a deeply nested descendant, not just a direct child.

If you find yourself writing the same [&:has(...)] variant repeatedly, Tailwind v4 lets you define custom variants in your CSS config file with @variant. That gives you a named shorthand like has-checked: that reads much cleaner at scale. In Tailwind v3 you'd have used addVariant in the plugin API — v4 makes it a one-liner in the CSS layer itself.

/* tailwind.css — v4 custom variant */
@import "tailwindcss";

@variant has-checked (&:has(input:checked));
@variant has-focus (&:has(:focus-visible));
```

```html
<!-- After defining the variant, markup gets much cleaner -->
<div class="border has-checked:border-blue-500 has-checked:bg-blue-50 ...">
  <input type="checkbox" />
</div>

Real Patterns You Can Ship Today

Let's go beyond toy examples. Here are patterns that show up in real product work, all doable without a single line of JS after switching to :has().

Floating label inputs. The classic 'label floats up when the input has a value' pattern used to need an onBlur handler or a controlled input watching for empty strings. With :has() and the :placeholder-shown pseudo-class, the parent fieldset itself can drive the label position. ``html <div class="relative [&:has(input:not(:placeholder-shown))_label]:-translate-y-5 [&:has(input:not(:placeholder-shown))_label]:text-xs [&:has(input:not(:placeholder-shown))_label]:text-blue-500"> <input placeholder=" " class="peer w-full border rounded-lg px-3 pt-5 pb-2" /> <label class="absolute left-3 top-3.5 text-sm text-zinc-400 transition-all pointer-events-none"> Email address </label> </div> ``

Active nav items. If your sidebar navigation renders <a aria-current="page"> on the active link, you can style the entire <li> wrapper without touching React state. ``html <li class="rounded-lg [&:has([aria-current='page'])]:bg-zinc-100 [&:has([aria-current='page'])]:font-semibold"> <a href="/dashboard" aria-current="page" class="block px-3 py-2">Dashboard</a> </li> ``

Form validation states. Style a fieldset or a form section red when it contains an :invalid input — no external validation library needed for the visual layer. ``html <fieldset class="space-y-2 [&:has(:invalid)]:ring-2 [&:has(:invalid)]:ring-red-400 rounded-xl p-4"> <input type="email" required class="w-full border rounded-md px-3 py-2" /> <p class="text-xs text-zinc-500">We'll never share your email.</p> </fieldset> ``

In practice, you'll find that :has() pairs extremely well with design systems that already lean on semantic HTML and ARIA attributes. Empire UI's components are built this way — check the box shadow generator and similar tools to see how interactive states are wired up without excessive JS overhead.

Performance, Specificity, and Browser Gotchas

:has() with complex argument selectors can trigger more style recalculations than a simple class toggle, because the browser has to re-evaluate the relational match whenever *any* descendant changes. For 99% of UI work this is imperceptible. Where it bites you is inside large virtualized lists or tables with thousands of visible rows — using :has() on the row element means every keystroke in any input potentially retriggers style recalc across all rows.

Specificity is exactly what you'd expect: :has(.foo) contributes the specificity of .foo to the overall selector. So div:has(.active) has the same specificity as div .active — one class, one element. Don't let that surprise you when utility overrides stop working; check your selector weight.

As of mid-2026 browser support is essentially universal: Chrome 105+, Firefox 121+, Safari 15.4+. The only real gap is Firefox pre-121, which you can test for with @supports selector(:has(*)). That's a CSS feature query specifically designed for relational selectors. Wrap your fallback in there rather than shipping a separate JS polyfill.

/* Fallback for old Firefox */
.card { background: white; }

@supports selector(:has(*)) {
  .card:has(input:checked) {
    background: rgb(239 246 255);
    border-color: rgb(59 130 246);
  }
}

One more thing — Tailwind's JIT engine (both v3 and v4) generates the :has() utility on-demand, so you don't pay for unused variants in production. The generated CSS is a single selector per utility class. Keep your arbitrary variants consistent in spelling so the deduplication works properly.

Combining :has() With Other Tailwind Variants

Where things get genuinely interesting is stacking :has() with other Tailwind variants. Dark mode, responsive prefixes, group, and peer all compose with arbitrary variants.

<!-- Only apply the has-checked highlight on md and above, in dark mode -->
<div class="border md:dark:[&:has(input:checked)]:border-blue-400 md:dark:[&:has(input:checked)]:bg-blue-950">
  <input type="checkbox" />
  <span>Dark mode feature flag</span>
</div>

Look, you probably don't need that exact combination — but knowing it works means you're not painting yourself into a corner. The Tailwind variant system is composable all the way down, and :has() plugs right into that.

You can also combine :has() with the group and peer variants for multi-level relational styling. Say you need a form section to change *and* a sibling element to react — peer handles the sibling, :has() handles the ancestor. They're complementary tools, not competing ones. If you're building something like a color-theme picker — where checking a radio input should change a whole preview pane — you'd reach for :has() on the containing wrapper rather than trying to chain peer-* across unrelated subtrees.

For component libraries like the ones in the Empire UI glassmorphism collection, :has() opens up stateful glass effects that respond to interaction without any runtime overhead. A glass card that deepens its blur when a button inside it is focused, purely in CSS, is genuinely achievable in 2026.

When to Still Use JavaScript

:has() doesn't replace JS state management. It replaces *visual* state that used to require JS. That's a narrower (but still very useful) scope.

You still need JavaScript when: the state lives in your application data layer (a Redux store, a Zustand atom, a server response), when you need to animate between states with JS-driven values (spring physics, scroll-driven position), or when accessibility requires you to communicate state to assistive technology via ARIA that can't be inferred from DOM structure alone.

The sweet spot for :has() is purely presentational reactions to native HTML states — :checked, :focus, :valid, :invalid, :empty, :placeholder-shown. These are states the browser tracks for free. Piggyback on them.

That said, don't overthink the boundary. If writing a useState toggle is faster and clearer to your team than a 90-character arbitrary Tailwind variant, do that. The goal is shipping good UI, not CSS purity. :has() is a sharp tool — useful exactly when it fits.

FAQ

Does Tailwind v3 support :has() or do I need v4?

v3 supports it via arbitrary variants — [&:has(input:checked)]:bg-blue-50 works fine. v4 just adds native @variant support so you can define clean shorthand names without a plugin.

Will :has() cause performance issues in complex UIs?

Only in edge cases — large virtualized lists where every row has a :has() selector can trigger extra style recalcs. For standard page UI, menus, forms, and cards, you won't notice it.

Can I use :has() to select a parent based on a deeply nested child?

Yes. :has() checks descendants at any depth, not just direct children. :has(.foo) matches if .foo exists anywhere inside the element.

How do I handle browsers that don't support :has()?

Wrap your :has() rules in @supports selector(:has(*)) and provide a plain fallback outside that block. Old Firefox (pre-121) is the main target — all modern browsers are covered.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind CSS OKLCH Colors: Perceptually Uniform Palettes in v4Tailwind Container Queries: @container in Tailwind v4:has() Parent Selector in CSS: The One You've Been Waiting ForTailwind vs CSS Modules in 2026: Utility-First vs Scoped Styles