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.
Table of Contents
- 1. What makes CSS :has() fundamentally different
- 2. Syntax of the Tailwind has variant
- 3. has-[:checked]: forms and toggle UI
- 4. has-[input:focus]: focus states on wrappers
- 5. has-[.class]: controlling component states
- 6. Conditional layouts with has in Tailwind
- 7. not-has: negating the parent selector
- 8. group-has and peer-has as a composition pattern
- 9. has vs. group vs. peer, a direct comparison
- 10. Summary
- 11. FAQ
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.