Tailwind CSS v4 Variants and Selectors: hover, group, peer, @custom-variant
AI generated
</>
tw
Tailwind CSS v4 · Variants · Selectors · group · peer · has
Tailwind CSS v4 Variants and Selectors
group, peer, has, not and @custom-variant

Variants are the heart of the Tailwind approach. Tailwind CSS v4 extends them considerably: named groups, the has: selector, improved peer logic, and @custom-variant for project-specific states, all without writing a single custom CSS file.

14 min read hover · focus · group · peer · has · not · dark · @custom-variant Tailwind CSS v4.x · Oxide compiler

1. What Tailwind variants are and how they work

In Tailwind CSS, a variant is a prefix placed in front of a utility class that defines under which condition that class is applied. hover:bg-sky-500 means: set the background to sky-500, but only when the element is hovered. Internally Tailwind generates the CSS selector .hover\:bg-sky-500:hover { background-color: ... } for this. The decisive difference from traditional CSS: the developer writes the state directly in the HTML on the element, instead of writing separate CSS rules for every state.

In Tailwind CSS v4 the variant system has become more efficient thanks to the Oxide compiler and more powerful thanks to new CSS features. Variants can be combined arbitrarily: dark:hover:focus:bg-sky-600 is a valid expression, even if it rarely makes sense. The combination dark:md:hover:bg-sky-600, dark mode, from the md breakpoint upward, on hover, is a frequently used pattern for responsive dark mode components. The processing order of the variants matches the order of the prefixes from outside to inside: first the dark mode context is checked, then the breakpoint, then the hover state.

2. State variants: hover, focus, active, disabled

The state variants in Tailwind map CSS pseudo classes. hover: generates :hover, focus: generates :focus, active: generates :active, disabled: generates :disabled. Especially important for accessibility-oriented styling: focus-visible: instead of focus:. The difference: :focus-visible shows the focus ring only during keyboard navigation, not on a mouse click. This matches current accessibility recommendations for custom focus styles.

Form-related state variants are complete in v4: checked:, indeterminate:, placeholder-shown:, required:, valid:, invalid:, in-range:, out-of-range:, read-only:. These variants enable complete form validation visualization without JavaScript: an error state for an invalid input field is defined directly in the HTML with invalid:border-red-500 invalid:bg-red-50. The placeholder: variant targets the placeholder text: placeholder:text-slate-400.


<!-- State variants for a complete form input with validation states -->
<div class="space-y-2">
  <label class="block text-sm font-semibold text-slate-700" for="email">
    Email address
  </label>
  <input
    id="email"
    type="email"
    required
    class="
      w-full rounded-xl border border-slate-300 px-4 py-2.5 text-slate-900
      placeholder:text-slate-400
      focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:border-sky-500
      disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-50
      invalid:border-red-400 invalid:bg-red-50 invalid:text-red-900
      invalid:focus-visible:ring-red-400
      valid:border-green-400 valid:bg-green-50
      transition-colors duration-200
    "
    placeholder="name@example.com"
  >
  <!-- Error message, shown only when input is invalid (CSS :invalid state) -->
  <p class="text-sm text-red-600 hidden peer-invalid:block">
    Please enter a valid email address.
  </p>
</div>

3. Responsive variants: sm, md, lg, xl, 2xl

Responsive variants in Tailwind follow the mobile-first principle: a class without a prefix applies to all screen sizes, a prefix like md: applies from the defined breakpoint upward. The default breakpoints in Tailwind v4 are: sm (640px), md (768px), lg (1024px), xl (1280px), 2xl (1536px). In v4, breakpoints can be extended or overridden in @theme { --breakpoint-xs: 475px; }.

A common misunderstanding with responsive variants: the class md:hidden hides an element from the md breakpoint upward, not below it. To hide an element only on small screens you need hidden md:block: hidden by default, displayed as a block element from md upward. For exclusive width ranges there are no built-in max-*-variants in v4, but you can use max-md: as a prefix: max-md:flex-col sets flex-direction to column only below the md breakpoint. These max-*-variants are available by default in Tailwind v4.

4. group and group-hover: parent-child interactions

The group variant solves a fundamental CSS limitation: child elements cannot react to the state of a parent element in plain CSS. group on the parent element combined with group-hover: on a child element generates the selector .group:hover .group-hover\:bg-sky-50. The child element reacts when the parent element is hovered, without a JavaScript event listener. In Tailwind v4 all state variants are available as group variants: group-focus:, group-active:, group-disabled:, group-checked:.

New in Tailwind v4: named group instances. In v3 you could only use one group level; with nested groups, group-hover: reacted to the innermost group. In v4 groups get names: group/card on the outer container and group/button on an inner container. Child elements can then react specifically to a particular group: group-hover/card:bg-sky-50 only reacts when the card container is hovered, not when the button container is hovered. This makes complex nested hover interactions possible without JavaScript.


<!-- Named group instances: Tailwind v4 feature -->
<div class="group/card rounded-2xl border border-slate-200 p-6 hover:border-sky-300 hover:shadow-lg transition-all duration-200">
  <div class="flex items-start justify-between mb-4">
    <div>
      <h3 class="font-bold text-slate-900 group-hover/card:text-sky-700 transition-colors">
        Product title
      </h3>
      <p class="text-sm text-slate-500 group-hover/card:text-slate-600 transition-colors">
        Category · Brand
      </p>
    </div>

    <!-- Nested interactive element with its own group -->
    <div class="group/btn relative">
      <button class="
        size-10 rounded-lg bg-slate-100
        group-hover/card:bg-sky-100
        group-hover/btn:bg-sky-500
        flex items-center justify-center transition-colors duration-200
      ">
        <!-- Icon reacts to button hover (group/btn), not card hover (group/card) -->
        <svg class="w-5 h-5 text-slate-400 group-hover/btn:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
        </svg>
      </button>
      <!-- Tooltip reacts only to btn hover -->
      <span class="absolute -top-9 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-xs px-2 py-1 rounded opacity-0 group-hover/btn:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
        Add to favorites
      </span>
    </div>
  </div>
</div>

5. peer: sibling selectors without JavaScript

The peer variant uses the CSS sibling selector ~. An element with the peer class becomes a reference element. All subsequent siblings can react to the state of the peer element with peer-*: prefixes. The classic example: a checkbox state controls the display of a following label or additional content without JavaScript. peer-checked:bg-sky-500 on an element after a checkbox peer colors the background when the checkbox is checked.

In Tailwind v4, peer instances can also be named: peer/newsletter as a class on an input, then peer-checked/newsletter:opacity-100 on a subsequent element. This allows multiple independent peer relationships at the same level. Important limitation: peer selectors only work in the forward direction, the peer element must come before the reacting element in the DOM. This is due to the CSS specification of the ~ selector, not Tailwind. Anyone who wants to react to a preceding element must adjust the DOM order or fall back to JavaScript.

6. has and not: modern CSS selectors in Tailwind v4

Tailwind v4 supports native CSS selectors like :has() and :not() as variants. The has: variant is especially powerful because it enables a true parent selector in CSS for the first time: an element can be styled based on its child elements. has-[input:checked]:bg-sky-50 on a container colors the background when any checkbox input contained within it is checked. In forms, list items, and interactive cards, this is a frequently needed pattern.

The not: variant applies a class when an element does not satisfy a selector. not-last:border-b sets a bottom border for every element except the last one, a very common pattern for list dividers that previously required border-b last:border-b-0. With not-last:border-b the expression is semantically correct and needs no override on the last element. Likewise: not-disabled:hover:bg-sky-50 activates the hover effect only for elements that are not disabled, a more elegant alternative to manual combination logic in JavaScript.


<!-- has: variant, parent reacts to children state (Tailwind v4) -->

<!-- Form row: highlight when any child input is focused -->
<div class="has-[input:focus]:bg-sky-50 has-[input:focus]:ring-1 has-[input:focus]:ring-sky-200 rounded-lg p-4 transition-all">
  <label class="block text-sm font-semibold text-slate-700 mb-1">Company name</label>
  <input type="text" class="w-full border-0 bg-transparent focus:outline-none text-slate-900 placeholder:text-slate-400" placeholder="Acme Corp Ltd">
</div>

<!-- not-last: variant, border-b on all items except the last -->
<ul class="divide-y-0">
  <li class="not-last:border-b not-last:border-slate-200 px-4 py-3 flex items-center gap-3">
    <span class="size-8 rounded-full bg-sky-100 flex items-center justify-center text-sky-700 text-xs font-bold">A</span>
    <span class="text-slate-800 font-medium">First entry</span>
  </li>
  <li class="not-last:border-b not-last:border-slate-200 px-4 py-3 flex items-center gap-3">
    <span class="size-8 rounded-full bg-sky-100 flex items-center justify-center text-sky-700 text-xs font-bold">B</span>
    <span class="text-slate-800 font-medium">Second entry</span>
  </li>
  <li class="not-last:border-b not-last:border-slate-200 px-4 py-3 flex items-center gap-3">
    <span class="size-8 rounded-full bg-sky-100 flex items-center justify-center text-sky-700 text-xs font-bold">C</span>
    <span class="text-slate-800 font-medium">Last entry, no border-b</span>
  </li>
</ul>

7. dark and forced-colors: appearance variants

The dark: variant is one of the most widely used variants in modern web projects. In Tailwind v4 it can be configured in two ways: media-based (default) reacts to the user's system appearance via @media (prefers-color-scheme: dark), selector-based reacts to a CSS class on the HTML element, such as dark. For projects offering a manual dark mode toggle, selector-based is the right choice. Configuration in v4 is done via @custom-variant dark (&:where(.dark, .dark *)) in the CSS.

New in Tailwind v4: the forced-colors: variant. It reacts to Windows high contrast mode (@media (forced-colors: active)). Elements that must remain recognizable and usable in high contrast mode can be explicitly styled for this mode with forced-colors:outline forced-colors:outline-current. This is an accessibility requirement for public web applications under WCAG 2.2 and EN 301 549. Tailwind makes this previously laborious styling accessible through a simple variant, without having to write your own @media forced-colors blocks.

8. Combining variants: what works, what does not

Variants in Tailwind CSS v4 can be freely combined. The order of the prefixes corresponds to the nesting of the generated CSS selectors: dark:md:hover:bg-sky-600 generates the selector that applies in dark mode, from the md breakpoint upward, on hover. Theoretically an arbitrary number of combinations is possible. Practically, combinations of up to three levels make sense. More levels produce valid CSS, but they make HTML classes hard to read and are often a sign that the component carries too much logic in the template.

Variant Generated selector Use case
hover:bg-sky-500 .hover\:bg-sky-500:hover Simple hover effect
group-hover/card:opacity-100 .group\/card:hover .group-hover\/card\:opacity-100 Named group, parent hover
dark:md:hover:bg-slate-700 @media (prefers-color-scheme: dark) { @media (min-width: 768px) { &:hover } } Dark mode + responsive + hover
has-[input:checked]:bg-sky-50 .has-[input:checked]\:bg-sky-50:has(input:checked) Parent reacts to child state
peer-invalid:text-red-600 .peer:invalid ~ .peer-invalid\:text-red-600 Sibling selector, forward direction

9. @custom-variant: defining your own variants

@custom-variant is the v4 way to define your own variants without having to write JavaScript plugins. In v3 this was possible via addVariant('name', selector) in the config; in v4 it happens directly in the CSS. The syntax is @custom-variant name { selector-expression; }. This enables project-specific variants for states Tailwind does not cover by default: theme classes on the body element, state classes from JavaScript frameworks, data-attribute based states.

Concrete examples of useful @custom-variant definitions: a theme-blue: variant reacts to data-theme="blue" on the root element, allowing entire color themes to be controlled through a single attribute. A js-loaded: variant reacts to a class that JavaScript sets after loading, useful for progressive enhancement patterns where certain styles only make sense with JavaScript. A print: variant for @media print is built into Tailwind v4, but it illustrates the flexibility of the system.


/* tailwind.css: Custom variants for project-specific states */
@import "tailwindcss";

/* Dark mode via selector (for manual toggle support) */
@custom-variant dark (&:where(.dark, .dark *));

/* Theme variants based on data attribute */
@custom-variant theme-blue (&:where([data-theme="blue"], [data-theme="blue"] *));
@custom-variant theme-green (&:where([data-theme="green"], [data-theme="green"] *));

/* Progressive enhancement: styles active only after JS loads */
@custom-variant js-ready (&:where(.js-loaded, .js-loaded *));

/* Reduced motion accessibility variant */
@custom-variant motion-safe {
  @media (prefers-reduced-motion: no-preference) {
    &:where(*) { @slot; }
  }
}

/* Magento-specific: active navigation item */
@custom-variant nav-active (&:where(.active, .current));

/*
  Usage in HTML:
  <body data-theme="blue">
    <nav>
      <a class="nav-active:font-bold nav-active:text-sky-600 theme-blue:text-blue-600" href="/">Home</a>
    </nav>
  </body>
*/

10. Summary

Tailwind CSS v4 variants and selectors cover the entire spectrum of modern CSS state handling, from simple hover to nested named groups and native has: selectors. The result: UI interactions like hover effects, form validation, dark mode switching, and theme-based styling are fully achievable without JavaScript event listeners. This reduces JavaScript dependencies, improves performance, and simplifies maintenance.

For projects built on Tailwind v4, investing time in understanding group, peer, has:, and @custom-variant pays off in particular. These four concepts solve the majority of cases where JavaScript event listeners were previously used for purely visual state changes. @custom-variant makes the variant system extensible for any project context, whether Magento theme classes, Alpine.js states, or data attributes from a content management system.

Tailwind CSS v4 variants, the essentials at a glance

group and peer

Usable with names in v4: group/card and group-hover/card:. Enables several independent groups in nested layouts without JavaScript.

has: and not:

True parent selectors: has-[input:checked]:bg-sky-50. not-last:border-b replaces the border-b-last:border-b-0 pattern. Native CSS, no hack.

@custom-variant

Define your own variants in CSS, no JavaScript plugin needed anymore. For theme classes, data attributes, and framework states.

Combinability

Variants freely combinable: dark:md:hover:bg-sky-600. Order equals nesting of the selectors. Up to three levels makes sense in practice.

11. FAQ: Tailwind CSS v4 Variants and Selectors

1What is a Tailwind variant?
A prefix that defines under which condition a class is applied. hover:bg-sky-500 generates internally .hover\:bg-sky-500:hover { ... }.
2New about group in v4?
Named groups: group/card and group-hover/card:. Several nested groups controllable independently, no JavaScript needed.
3How does has: work in v4?
Maps native CSS :has(). has-[input:checked]:bg-sky-50: container reacts to a checked checkbox inside it. A true parent selector in CSS.
4focus: vs. focus-visible:?
focus: always on focus. focus-visible: only during keyboard navigation. For custom focus styles following WCAG recommendations, always use focus-visible:.
5Custom variants in v4?
With @custom-variant in the CSS. No JavaScript plugin needed. For theme classes, data attributes, and framework states.
6peer forward only?
Yes. The CSS ~ selector only works for subsequent siblings. The peer element must come before the reacting element in the DOM.
7Combining variants?
dark:md:hover:bg-sky-600 is valid. Up to three levels makes sense in practice. Order equals nesting of the generated selectors.
8forced-colors: variant?
Reacts to Windows high contrast mode. Important for accessibility under WCAG 2.2. Explicit styling for high contrast without your own @media blocks.
9Dark mode with manual toggle in v4?
@custom-variant dark (&:where(.dark, .dark *)) in the CSS. JavaScript sets the class .dark on the HTML element. dark: then reacts to the class instead of the system setting.
10not-last: and not-first: useful?
not-last:border-b replaces border-b last:border-b-0. not-first:mt-4 replaces mt-4 first:mt-0. More semantically precise, shorter HTML code.