Tailwind has: The CSS :has() Variant for Context-Aware Styles
AI generated
</>
tw
Tailwind CSS · CSS :has() · Parent Selector · Tailwind v4
Tailwind has:
The CSS :has() Variant for Context-Aware Styles

CSS :has() is the first true parent selector in the history of CSS. In Tailwind v4 you use it directly in the markup: has-[:checked], has-[input:focus], has-[.error]. It lets styles react to the state of child elements, with no JavaScript and no custom CSS file.

13 min read has-[:checked] · has-[input:focus] · has-[.error] · group-has Tailwind CSS v4 · Chrome 105+ · Firefox 121+ · Safari 15.4+

1. What makes CSS :has() fundamentally different

Since the earliest days of CSS there has been a fundamental limitation: CSS can only select forward. A selector like div p selects p elements inside a div, but it can never select a div based on what it contains. For decades developers had to reach for JavaScript for that kind of requirement, attaching an event listener, traversing the DOM, and manually adding classes. CSS :has() breaks that limitation and is therefore the first true parent selector in the history of the language.

In Tailwind v4 this capability is available directly in the utility system as the Tailwind has variant. has-[:checked] on a container element matches when any descendant of the container has the :checked state. has-[input:focus] matches when an input element inside the container is focused. has-[.error] matches when an element with the class error exists inside the container. These Tailwind has classes generate exactly the CSS :has() construct under the hood, without you ever touching a CSS file.

2. Syntax of the Tailwind has variant

The Tailwind has variant follows the same pattern as other Tailwind modifiers: has-[SELECTOR]:UTILITY. The selector in square brackets is any CSS selector applied to a descendant of the element. has-[:checked]:bg-sky-50 sets the background to sky-50 when the element has a checked checkbox as a descendant. has-[input:focus]:ring-2 adds a ring when an input is focused. The syntax is flexible: type selectors, class selectors, attribute selectors and pseudo-classes are all possible.

Combinations with other modifiers work too: dark:has-[:checked]:bg-slate-800 for dark mode, sm:has-[img]:grid-cols-2 for responsive conditional layouts. In Tailwind v4 you can also extend the has variant with arbitrary selectors via the arbitrary-value syntax: has-[.product-card:hover] or has-[input[type='radio']:checked]. The result is a fully declarative approach to CSS that needs not a single line of JavaScript.


<!-- has-variant syntax examples in Tailwind CSS -->

<!-- Container highlights when any child input is focused -->
<div class="border border-slate-200 rounded-xl p-4
            has-[input:focus]:border-sky-500
            has-[input:focus]:ring-2
            has-[input:focus]:ring-sky-100
            transition-all duration-200">
  <input type="text" placeholder="Search..." class="w-full outline-none text-sm text-slate-700">
</div>

<!-- Card changes style when internal checkbox is checked -->
<label class="block border-2 border-slate-200 rounded-2xl p-4 cursor-pointer
              has-[:checked]:border-sky-500
              has-[:checked]:bg-sky-50
              transition-all duration-200">
  <input type="radio" name="plan" value="pro" class="sr-only">
  <span class="font-semibold text-slate-800 has-[:checked]:text-sky-700">Pro Plan</span>
  <p class="text-sm text-slate-500 mt-1">Unlimited projects, priority support</p>
</label>

<!-- Form wrapper that reacts to validation state -->
<div class="has-[:invalid]:border-red-300 has-[:invalid]:bg-red-50
            has-[:valid]:border-emerald-300 has-[:valid]:bg-emerald-50
            border-2 rounded-xl p-4 transition-colors duration-200">
  <input type="email" required class="w-full outline-none text-sm" placeholder="Email">
</div>

3. has-[:checked]: forms and toggle UI

Arguably the most common use case for Tailwind has is reacting to checkbox and radio states. The has-[:checked] pattern on a container reacts whenever any of its child inputs is checked or selected. This is more powerful than the peer-checked approach because has requires no specific DOM order and detects deeply nested inputs as well.

For pricing tables and option-selection UIs, has-[:checked] in Tailwind is the perfect tool: each option card gets a hidden radio input. The card itself receives has-[:checked]:ring-2 has-[:checked]:ring-sky-500 has-[:checked]:bg-sky-50. When the user clicks the card, it visually switches into the selected state. The label (the clickable area) can be the entire card, no JavaScript for event handling, no Alpine.js x-model. The browser's native form logic takes care of everything.


<!-- Pricing plan selector using has-[:checked], pure CSS -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">

  <!-- Starter plan -->
  <label class="relative block border-2 border-slate-200 rounded-2xl p-6 cursor-pointer
                has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50 has-[:checked]:shadow-lg
                hover:border-slate-300 transition-all duration-200">
    <input type="radio" name="pricing" value="starter" class="sr-only" checked>
    <!-- Checkmark badge: only visible when selected -->
    <div class="absolute top-3 right-3 w-6 h-6 rounded-full bg-sky-500 flex items-center justify-center
                opacity-0 has-[:checked]:opacity-100 transition-opacity">
      <svg class="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
      </svg>
    </div>
    <p class="font-bold text-slate-800 text-lg mb-1">Starter</p>
    <p class="text-3xl font-bold text-slate-900 mb-3">€ 0 <span class="text-sm font-normal text-slate-500">/month</span></p>
    <ul class="text-sm text-slate-600 space-y-1">
      <li>1 project</li>
      <li>5 GB storage</li>
      <li>Community support</li>
    </ul>
  </label>

  <!-- Pro plan -->
  <label class="relative block border-2 border-slate-200 rounded-2xl p-6 cursor-pointer
                has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50 has-[:checked]:shadow-lg
                hover:border-slate-300 transition-all duration-200">
    <input type="radio" name="pricing" value="pro" class="sr-only">
    <p class="font-bold text-slate-800 text-lg mb-1">Pro</p>
    <p class="text-3xl font-bold text-slate-900 mb-3">€ 29 <span class="text-sm font-normal text-slate-500">/month</span></p>
    <ul class="text-sm text-slate-600 space-y-1">
      <li>10 projects</li>
      <li>50 GB storage</li>
      <li>Email support</li>
    </ul>
  </label>

  <!-- Enterprise plan -->
  <label class="relative block border-2 border-slate-200 rounded-2xl p-6 cursor-pointer
                has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50 has-[:checked]:shadow-lg
                hover:border-slate-300 transition-all duration-200">
    <input type="radio" name="pricing" value="enterprise" class="sr-only">
    <p class="font-bold text-slate-800 text-lg mb-1">Enterprise</p>
    <p class="text-3xl font-bold text-slate-900 mb-3">€ 99 <span class="text-sm font-normal text-slate-500">/month</span></p>
    <ul class="text-sm text-slate-600 space-y-1">
      <li>Unlimited projects</li>
      <li>1 TB storage</li>
      <li>Priority support</li>
    </ul>
  </label>

</div>

4. has-[input:focus]: focus states on wrappers

A classic problem in form design: you want to highlight the entire wrapper around an input field when the field is focused, not just the field itself. With :focus-within this was already possible, but it fires for every focusable element inside the wrapper. Tailwind has offers more precision: has-[input:focus] only matches when specifically an input element is focused, not a button or a link in the same container.

This is especially valuable for search fields with icons and shortcut hints: the entire search wrapper should highlight when the user clicks into the search field. With has-[input:focus]:border-sky-500 has-[input:focus]:shadow-md on the wrapper, that is exactly what happens, no JavaScript, no onfocus event, no Alpine.js. The icon in the wrapper can switch its own state too with group-has-[input:focus], once you additionally mark the wrapper as a group.

5. has-[.class]: controlling component states

Tailwind has can react not only to pseudo-classes like :checked or :focus, but to any CSS selector, including class selectors. has-[.error] on a form wrapper matches when any child element has the class error. This is particularly useful for component systems where JavaScript (or Alpine.js) sets classes and the layout should react to them.

The pattern: Alpine.js sets the class error on a validation element that is present in the DOM once an error has occurred. The parent form container has has-[.error]:border-red-300 has-[.error]:bg-red-50. It reacts automatically, without Alpine.js ever having to address the container directly. This pattern separates concerns: JavaScript manages the state (setting/removing a class), CSS reacts to the state (applying styles). The Tailwind has variant is the bridge between the two worlds.


<!-- has-[.class] reacts to dynamic class from Alpine.js -->
<div x-data="{ hasError: false, value: '' }"
     class="border-2 rounded-2xl p-6 transition-all duration-200
            has-[.field-error]:border-red-300
            has-[.field-error]:bg-red-50
            has-[.field-success]:border-emerald-300
            has-[.field-success]:bg-emerald-50">

  <label class="block text-sm font-semibold text-slate-700 mb-2">Username</label>

  <input type="text"
    x-model="value"
    @input="hasError = value.length > 0 && value.length < 3"
    class="w-full border border-slate-300 rounded-xl px-4 py-2.5 text-sm outline-none
           focus:border-sky-400 focus:ring-2 focus:ring-sky-100">

  <!-- Error element: class field-error triggers has-[.field-error] on parent -->
  <p x-show="hasError && value.length > 0"
     class="field-error mt-2 text-xs text-red-600 font-medium">
    At least 3 characters required.
  </p>

  <!-- Success element: class field-success triggers has-[.field-success] -->
  <p x-show="!hasError && value.length >= 3"
     class="field-success mt-2 text-xs text-emerald-600 font-medium">
    Username is available.
  </p>
</div>

6. Conditional layouts with has in Tailwind

One of the most surprising applications of the Tailwind has variant is conditional layout control. A grid container can switch its layout when it contains a specific child element: has-[img]:grid-cols-[1fr_2fr] switches to a two-column layout when the element contains an image. has-[aside]:grid-cols-[280px_1fr] adds a sidebar column when an aside element is present.

This pattern enables flexible CMS layouts where editors decide whether an image or a sidebar is included, and the layout adapts automatically without any template having to be adjusted. In Magento Hyva layouts this is particularly interesting: a product container can automatically take on a different layout when the product gallery or the configuration options are present in the DOM. The Tailwind has variant reacts to the DOM state itself, not to an explicit class.

7. not-has: negating the parent selector

Besides has-[...], Tailwind also supports negation: not-has-[...] or the combination has-[]:not-[...]. This is useful for styles that should only apply when a specific child is absent. An empty state, a container without list items, can be rendered as a centered empty area with not-has-[li]:flex not-has-[li]:items-center not-has-[li]:justify-center, which automatically turns back into a normal layout as soon as items are added.

In Tailwind v4 you write the negation as [&:not(:has([...]))]:utility or use the native not-has variant if configured in the project. This is an advanced pattern, but extremely helpful for CMS content and dynamic lists: the "no results" state and the normal list state differ only by classes on the same container, with no JavaScript needed to determine the state and set classes. The Tailwind has variant makes the DOM state itself the single source of truth for styling.

8. group-has and peer-has as a composition pattern

Tailwind has can be combined with group and peer to apply styles to arbitrary elements within the component tree. The group-has-[input:checked]:... pattern on a child element reacts to whether the nearest ancestor group element contains a checked input anywhere within it. This is more powerful than group-has alone, because the node to be styled can sit at any depth within the child tree.

Take an example: a complex form card where a confirmation message should appear at the bottom once a particular option is selected anywhere within the card. The confirmation message is neither a direct sibling of the radio input (no peer possible) nor a direct child (no simple has). With group on the card and group-has-[:checked]:block on the confirmation message, you solve this cleanly: the card watches its entire child tree as a group, and the confirmation message reacts to the group's state.


<!-- group + has combination: complex multi-level state propagation -->
<div class="group border border-slate-200 rounded-2xl overflow-hidden">

  <div class="p-6">
    <h3 class="font-bold text-slate-800 mb-4">Choose delivery option</h3>

    <div class="space-y-3">
      <label class="flex items-center gap-3 cursor-pointer">
        <input type="radio" name="shipping" value="standard" class="peer accent-sky-500">
        <span class="text-sm text-slate-700 peer-checked:font-semibold peer-checked:text-sky-700">
          Standard shipping (3 to 5 days), free
        </span>
      </label>
      <label class="flex items-center gap-3 cursor-pointer">
        <input type="radio" name="shipping" value="express" class="peer accent-sky-500">
        <span class="text-sm text-slate-700 peer-checked:font-semibold peer-checked:text-sky-700">
          Express shipping (1 to 2 days), € 4.99
        </span>
      </label>
      <label class="flex items-center gap-3 cursor-pointer">
        <input type="radio" name="shipping" value="same-day" class="peer accent-sky-500">
        <span class="text-sm text-slate-700 peer-checked:font-semibold peer-checked:text-sky-700">
          Same-day delivery, € 12.99
        </span>
      </label>
    </div>
  </div>

  <!-- This confirmation bar is hidden until ANY radio in the group is checked -->
  <!-- group-has-[:checked] watches the entire group's subtree -->
  <div class="hidden group-has-[:checked]:flex items-center gap-3 px-6 py-3 bg-sky-50 border-t border-sky-100">
    <svg class="w-4 h-4 text-sky-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
    </svg>
    <p class="text-sm text-sky-700 font-medium">Delivery option selected. Continue to the next step.</p>
  </div>

</div>

9. has vs. group vs. peer, a direct comparison

The three Tailwind mechanisms for state-based styling complement one another but each has different strengths. The choice depends on the DOM structure and the direction in which the state needs to propagate.

Mechanism Selector type DOM requirement Typical use case
has-[...] Parent reacts to child None, child can be at any depth Container reacting to a checkbox, input focus, error class
group-hover Parent to child (parent's pseudo-state) group set on the parent element Hover cascades, overlays on cards, navigation menus
peer-checked Sibling to following sibling Peer target must follow the peer in the HTML Checkbox as toggle, floating labels, accordions
group-has Parent watches a child, controls any child group on container, group-has-[...] on target Confirmation elements that aren't direct siblings
not-has-[...] Parent reacts to a child's absence None Empty states, fallback layouts without certain elements

The rule of thumb: has-[...] directly on an element is the first choice when the element itself should change its own style based on a child's state. group-has comes into play when the element to be styled is far from the observed element and you need to communicate through a shared parent container. peer-checked remains the simplest option for direct sibling relationships.

Mironsoft

Tailwind CSS v4 · Hyva Themes · Alpine.js

Modern CSS features, used the right way?

We use Tailwind has, group and peer consistently to build interactive interfaces that rely on as little JavaScript as possible, faster, more robust, and easier to maintain.

has analysis

Checking existing JavaScript state logic for has/group/peer suitability

Form UI

Building forms with has-based validation, floating labels and CSS-only feedback

Magento Hyva

Optimizing product pages and checkout UI with Tailwind has

10. Summary

The Tailwind has variant brings CSS :has() into the utility-first system and thereby makes the first true parent selector in the history of CSS accessible to Tailwind developers. has-[:checked] for option cards and pricing tables, has-[input:focus] for wrapper highlighting, has-[.error] for reacting to classes set by Alpine.js, all of it works without JavaScript, without event listeners, and without manual DOM manipulation. The DOM structure itself is the single source of truth.

Combined with group-has and peer-has, composition patterns emerge that allow complex state propagation across arbitrary DOM depths. The practical benefit: less JavaScript, smaller bundles, better performance. At the same time the declarative nature of the Tailwind approach is preserved, no dynamic class-setting in JavaScript, no drift between styles and logic. The Tailwind has variant is one of the most important features of Tailwind v4 and quickly becomes indispensable in modern projects.

Tailwind has, the essentials at a glance

Syntax

has-[SELECTOR]:UTILITY. Any CSS selector in square brackets: has-[:checked], has-[input:focus], has-[.error], has-[img].

When has instead of peer?

has when the element should style itself. peer when a following sibling should be controlled. group-has for deeply nested targets.

Browser support

Chrome 105+, Firefox 121+, Safari 15.4+. All modern browsers since 2022/2023. Fall back to group/peer for legacy support.

Combination

Combinable with dark:, sm:, lg: and other modifiers. group-has-[...] and peer-has-[...] are also available for cross-element communication.

11. FAQ: Tailwind has and the CSS :has() variant

1What is the Tailwind has variant?
CSS :has() as a Tailwind utility: has-[:checked]:bg-sky-50 sets the background when a checked input exists as a descendant. The first true parent selector in CSS.
2Browser support for :has()?
Chrome 105+ (Aug 2022), Firefox 121+ (Dec 2023), Safari 15.4+ (Mar 2022). All modern evergreen browsers.
3has instead of peer-checked: when?
has when the element should style itself and the input can sit at any depth. peer-checked when a direct following sibling is controlled.
4has-[.class] for dynamic states?
JS/Alpine.js sets a class on a child. Container has has-[.error]:border-red-300. Cleanly separates state management (JS) from presentation (CSS).
5What is group-has?
group-has-[...] on a child: reacts when the parent group element contains a specific descendant. For distant targets with no sibling relationship.
6Combining has with other modifiers?
Yes: dark:has-[:checked]:bg-slate-800, sm:has-[img]:grid-cols-2, lg:has-[aside]:grid-cols-[280px_1fr]. All Tailwind modifiers can be combined.
7Building a pricing table with has-[:checked]?
A label element as a card with an sr-only radio input. Card: has-[:checked]:ring-2 has-[:checked]:bg-sky-50. Click the card, radio gets checked, card is marked. No JS.
8not-has in Tailwind?
not-has-[li]:flex not-has-[li]:justify-center shows a centered empty area when no list items are present, the reverse of the normal layout.
9Does has replace focus-within?
focus-within fires for every focusable element. has-[input:focus] only for input elements, more precise and controlled.
10has vs. group: what's the difference?
has styles the element itself based on descendant state. group styles children based on the state of the parent itself. Opposite directions.