Tailwind group and peer: selectors for interactive UI without JavaScript
AI generated
</>
tw
Tailwind CSS · group · peer · CSS-only interactions
Tailwind group and peer:
selectors for interactive UI without JavaScript

Hover cascades, form validation and complex UI states: with Tailwind's group and peer modifiers you can describe interactions that other projects reach for Alpine.js or React to build. The result is lean HTML with zero JavaScript overhead.

12 min read group-hover · peer-checked · group-has · group-focus-within Tailwind CSS v4 · CSS :has() · Modern browsers

1. The concept behind group and peer

Tailwind CSS group and peer are modifier classes that expose CSS selectors from the browser's native cascade without requiring you to write any custom CSS. The basic principle: you mark a parent element with the class group, and inside child elements you can then write classes such as group-hover:opacity-100. That class kicks in exactly when the parent element is in the hover state. It corresponds to the CSS selector .group:hover .child, Tailwind generates precisely that, but you write it directly on the child.

The difference between Tailwind group and Tailwind peer lies in direction: group works from parent to children (a downward relationship), while peer works from a sibling element to the following sibling (a forward relationship in the sense of the CSS adjacent sibling selector). The peer element gets the class peer, and the following sibling element uses classes such as peer-checked:block. Important: the peer target must come after the peer element in the HTML source. CSS cannot select backwards.

2. group-hover: hover cascades across components

The most common use case for Tailwind group is a hover cascade across card components. Without group, you would need JavaScript listening to the parent card's hover event for every child element that should animate when the card is hovered. With Tailwind group you instead write group on the card and the matching group-hover: class on every affected child element: group-hover:translate-y-0, group-hover:opacity-100, group-hover:text-sky-600. The browser handles the entire logic through its native CSS selector engine.

The result is interactions that are frame-perfect smooth, with no JavaScript event listeners, no requestAnimationFrame, no Alpine.js boilerplate. Combined with transition-all duration-300 on the child elements, this produces fluid animations. The pattern is especially effective for product cards in e-commerce systems: zooming an image, fading in an overlay, revealing a quick-add button, all driven by group-hover Tailwind classes.


<!-- Product card with group-hover cascade, no JavaScript needed -->
<div class="group relative overflow-hidden rounded-2xl bg-white border border-slate-200 shadow-sm hover:shadow-xl transition-shadow duration-300 cursor-pointer">

  <!-- Image with zoom effect on parent hover -->
  <div class="relative overflow-hidden aspect-square bg-slate-100">
    <img src="/product.jpg" alt="Product"
      class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105">

    <!-- Overlay appears on hover -->
    <div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
      <button class="bg-white text-slate-900 font-bold text-sm px-5 py-2.5 rounded-xl
        translate-y-4 group-hover:translate-y-0 transition-transform duration-300 shadow-lg">
        Add to cart
      </button>
    </div>
  </div>

  <!-- Text content with animated color change -->
  <div class="p-4">
    <h3 class="font-semibold text-slate-800 group-hover:text-sky-700 transition-colors duration-200">
      Product name
    </h3>
    <p class="text-sm text-slate-500 mt-1 group-hover:text-slate-700 transition-colors duration-200">
      Short description
    </p>
    <div class="flex items-center justify-between mt-3">
      <span class="text-lg font-bold text-slate-900">€ 49,99</span>
      <!-- Arrow icon slides in on hover -->
      <svg class="w-5 h-5 text-slate-300 group-hover:text-sky-500 group-hover:translate-x-1 transition-all duration-200"
        fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
      </svg>
    </div>
  </div>
</div>

3. Named groups for nested structures

As soon as multiple groups get nested, a card with interactive buttons that should themselves trigger hover effects, conflicts arise: every group-hover: class binds to the nearest ancestor group. Tailwind CSS resolves this with named groups. You write group/card on the outer card and group/button on the inner button. The child elements then target either group-hover/card: or group-hover/button: specifically.

Named Tailwind group modifiers are especially valuable in navigation menus with submenus: the main navigation is group/nav, each top-level menu item is group/item. The submenu opens with group-hover/item:block, and hover effects inside the submenu reference group/item independently of group/nav. That produces a fully working dropdown menu without a single line of JavaScript.

4. peer-checked: checkboxes and radios as UI controllers

The most powerful use case for Tailwind peer is using checkboxes and radio buttons as CSS-only UI controllers. The pattern: an <input type="checkbox"> with the class peer is placed in the HTML before the element that should be controlled. The controlled element gets peer-checked:block hidden, it is hidden by default and appears once the checkbox is checked. Combined with sr-only on the input element, this produces a fully CSS-driven toggle system.

This peer-checked Tailwind pattern enables, among other things: accordion components without JavaScript, tab navigations built with radio buttons, dark-mode toggles on a pure CSS basis, custom checkbox designs and star-rating interfaces. The advantage over Alpine.js: no JavaScript bundle, no flash of unstyled content, no hydration. The downside: the order in the HTML is strict, the peer target must come after the peer element.


<!-- CSS-only accordion using peer-checked, zero JavaScript -->
<div class="border border-slate-200 rounded-2xl overflow-hidden divide-y divide-slate-200">

  <!-- Accordion item 1 -->
  <div>
    <input type="checkbox" id="acc1" class="peer sr-only">
    <label for="acc1"
      class="flex items-center justify-between px-6 py-4 cursor-pointer font-semibold text-slate-800
             hover:bg-slate-50 transition-colors peer-checked:text-sky-700">
      How quickly are orders shipped?
      <svg class="w-5 h-5 text-slate-400 transition-transform duration-300 peer-checked:rotate-180"
        fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
      </svg>
    </label>
    <!-- Content hidden by default, shown when checkbox is checked -->
    <div class="max-h-0 overflow-hidden transition-all duration-300 peer-checked:max-h-48">
      <div class="px-6 pb-4 text-slate-600 text-sm">
        Orders placed on business days before 2:00 PM are shipped the same day.
        Delivery takes 1 to 3 business days.
      </div>
    </div>
  </div>

  <!-- Accordion item 2 -->
  <div>
    <input type="checkbox" id="acc2" class="peer sr-only">
    <label for="acc2"
      class="flex items-center justify-between px-6 py-4 cursor-pointer font-semibold text-slate-800
             hover:bg-slate-50 transition-colors peer-checked:text-sky-700">
      Which payment methods are accepted?
      <svg class="w-5 h-5 text-slate-400 transition-transform duration-300 peer-checked:rotate-180"
        fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
      </svg>
    </label>
    <div class="max-h-0 overflow-hidden transition-all duration-300 peer-checked:max-h-48">
      <div class="px-6 pb-4 text-slate-600 text-sm">
        We accept credit card, PayPal, SEPA direct debit and purchase on account.
      </div>
    </div>
  </div>

</div>

5. peer-focus and peer-valid for form logic

Tailwind peer is aware of every CSS pseudo-class of the element it is applied to. For form fields the most important ones are: peer-focus, peer-valid, peer-invalid, peer-required and peer-placeholder-shown. The classic floating-label pattern, a label that sits inside the input field and jumps up once the field is focused or filled, can be built entirely with peer-focus and peer-placeholder-shown in Tailwind.

For validation feedback you combine peer-invalid with peer-not-placeholder-shown: the error message only appears once the field is invalid and the user has already typed something (so the placeholder is no longer visible). That prevents every field from showing red the instant the form first loads. The same applies to success states with peer-valid. Combined with HTML5 validation attributes (required, type="email", minlength), the result is a complete client-side form validation without JavaScript.


<!-- Floating label input with validation, no JavaScript -->
<div class="relative">
  <input
    id="email"
    type="email"
    required
    placeholder=" "
    class="peer w-full border border-slate-300 rounded-xl px-4 pt-6 pb-2 text-sm text-slate-800
           focus:outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100
           invalid:[&:not(:placeholder-shown)]:border-red-400
           invalid:[&:not(:placeholder-shown)]:ring-red-100
           valid:[&:not(:placeholder-shown)]:border-emerald-400">

  <!-- Floating label: moves up when focused or filled -->
  <label for="email"
    class="absolute left-4 top-4 text-slate-400 text-sm pointer-events-none
           transition-all duration-200
           peer-focus:top-1.5 peer-focus:text-xs peer-focus:text-sky-600
           peer-[&:not(:placeholder-shown)]:top-1.5
           peer-[&:not(:placeholder-shown)]:text-xs">
    Email address
  </label>

  <!-- Validation messages -->
  <p class="mt-1 text-xs text-red-500 hidden peer-invalid:[&:not(:placeholder-shown)~&]:block">
    Please enter a valid email address.
  </p>
  <p class="mt-1 text-xs text-emerald-600 hidden peer-valid:[&:not(:placeholder-shown)~&]:block">
    Email address is valid.
  </p>
</div>

6. group-focus-within for input fields with overlay labels

group-focus-within is the focus-state counterpart of group-hover: the group reacts as soon as any child element receives focus. That is especially useful for input fields surrounded by icons or buttons: when the user focuses the input field, the entire field (including the icon wrapper) should be visually highlighted. Without group-focus-within you would need JavaScript listening to the input's focus event and manually applying CSS classes to the wrapper.

The pattern in practice: a div with group wraps a search-field icon and an input. The wrapper gets group-focus-within:border-sky-500 group-focus-within:ring-2 group-focus-within:ring-sky-100. The icon gets group-focus-within:text-sky-500. Everything responds in sync to focusing the input. Combined with peer classes for validation feedback, the result is a fully interactive form design built purely on CSS mechanisms.

7. group-has and peer-has: the forward selector

group-has and peer-has are built on the modern CSS selector :has() and are the most powerful feature in the Tailwind group/peer family. While group-hover reacts to the state of the group element itself, group-has reacts to whether the group element contains a specific child. group-has-[input:checked] triggers whenever the group element contains a checked checkbox anywhere in its depth, regardless of how deeply it is nested.

That enables patterns that were previously impossible with pure CSS: a parent layout element changes when a hidden checkbox in one of its children is checked. Or a card highlights itself when a radio button input embedded inside it is selected, even if that input is nested several levels deep. In Tailwind v4 you write this as group-has-[input:checked]:ring-2 on the visual element you want to affect. Peer-has works analogously for sibling elements that react to the :has() state of a preceding peer.

8. Practical example: a product card with multiple states

A complete practical example shows how Tailwind group and Tailwind peer can be combined. The requirement: a product card that reveals a quick-view button on hover, switches into a "saved" state when the wishlist checkbox is clicked, and gives the user immediate feedback, all without JavaScript. The checkbox acts as the peer controlling the entire visual state of the card: a checked heart button gets filled in, the card gets a blue border, a badge appears.

For this pattern you nest group and peer deliberately: the card is the group for all hover effects. The wishlist checkbox is the peer that controls the state of the card and the heart icon. The heart icon is simultaneously a peer target and part of the group. With named groups (group/card) and named peers (peer/wishlist), responsibilities stay clear even as more interactive elements are added.


<!-- Product card: group-hover + peer-checked for multiple states -->
<div class="group/card relative overflow-hidden rounded-2xl bg-white border border-slate-200
            peer-checked/wishlist:border-sky-400 peer-checked/wishlist:ring-2 peer-checked/wishlist:ring-sky-100
            shadow-sm hover:shadow-lg transition-all duration-300">

  <!-- Wishlist toggle, acts as peer for the card state -->
  <input type="checkbox" id="wl1" class="peer/wishlist sr-only">

  <!-- Image area -->
  <div class="relative aspect-square overflow-hidden bg-slate-50">
    <img src="/product.jpg" alt="Product" class="w-full h-full object-cover group-hover/card:scale-105 transition-transform duration-500">

    <!-- Wishlist button, changes on peer-checked -->
    <label for="wl1"
      class="absolute top-3 right-3 w-9 h-9 rounded-full bg-white/90 shadow-md flex items-center justify-center cursor-pointer
             hover:bg-white transition-colors duration-150">
      <!-- Outline heart: visible when NOT checked -->
      <svg class="w-5 h-5 text-slate-400 peer-checked/wishlist:hidden" 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>
      <!-- Filled heart: visible when checked -->
      <svg class="w-5 h-5 text-red-500 hidden peer-checked/wishlist:block" fill="currentColor" viewBox="0 0 24 24">
        <path 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>
    </label>

    <!-- Quick view button, slides up on group hover -->
    <div class="absolute inset-x-0 bottom-0 flex justify-center pb-3
                translate-y-full group-hover/card:translate-y-0 transition-transform duration-300">
      <button class="bg-white text-slate-800 font-semibold text-xs px-4 py-2 rounded-xl shadow-lg hover:bg-sky-600 hover:text-white transition-colors">
        Quick view
      </button>
    </div>
  </div>

  <!-- Product info -->
  <div class="p-4">
    <p class="text-xs font-semibold text-sky-600 uppercase tracking-wider mb-1">Category</p>
    <h3 class="font-semibold text-slate-800 group-hover/card:text-sky-700 transition-colors">Product name</h3>
    <p class="text-lg font-bold text-slate-900 mt-2">€ 79,00</p>
  </div>
</div>

9. group and peer patterns compared

The choice between Tailwind group, Tailwind peer and Alpine.js depends on the complexity of the interaction and the browser requirements.

Interaction Tool Tailwind class JS needed?
Card overlay on hover group group-hover:opacity-100 No
Accordion open/close peer peer-checked:max-h-48 No
Floating label peer peer-focus:top-1.5 peer-[&:not(:placeholder-shown)]:top-1.5 No
Mark card as "selected" group-has group-has-[input:checked]:ring-2 No
Complex state management Alpine.js x-data + :class Yes

The rule of thumb: anything based on CSS pseudo-classes (:hover, :focus, :checked, :valid, :has()) can be solved with Tailwind group and peer. As soon as you need dynamic state that cannot be expressed through native HTML elements, for example a modal opening after an API call, Alpine.js is the right complement. Both approaches are not mutually exclusive: many interactions can be covered largely with group and peer and only supplemented with Alpine.js for genuinely dynamic logic.

Mironsoft

Interactive UI with Tailwind CSS and Hyva Themes

Interactive UI without JavaScript overhead?

We use Tailwind group and peer consistently for every interaction that CSS can solve, and save Alpine.js for the cases where it is genuinely needed, meaning a smaller bundle and faster pages.

UI audit

Review existing Alpine.js interactions for group/peer suitability and simplify them

Component build

Implement cards, accordions, forms and navigations with group/peer

Hyva optimization

Implement Magento Hyva components with the minimum possible JavaScript footprint

10. Summary

Tailwind group and Tailwind peer are two of the most powerful modifiers in Tailwind CSS because they expose the full expressive power of native CSS selectors without any custom CSS. group controls child elements based on the state of the parent element: ideal for cards, navigations and overlays. peer controls sibling elements based on the state of a preceding sibling element: ideal for checkboxes as controllers, floating labels and validation feedback. Named groups and peers (group/name, peer/name) resolve conflicts in nested structures.

Combining both modifiers covers the majority of UI interactions needed in web projects. Hover cascades, accordions, floating labels, wishlist toggles, tab navigations, all of that works CSS-only. Only once state comes from API calls, user sessions or complex logic does Alpine.js become the sensible complement. The result is lightweight, fast interfaces free of JavaScript boilerplate for things the browser can already do natively.

Tailwind group and peer, the essentials at a glance

group

Mark the parent element with group. Child elements use group-hover:, group-focus: and so on. Named groups for nesting: group/card.

peer

The controlling element gets peer. The following sibling element reacts with peer-checked:, peer-focus:, peer-valid:. Must come after the peer in the HTML.

group-has / peer-has

Reacts to whether the element contains a specific child, based on CSS :has(). Enables forward selection across any depth.

When is Alpine.js needed?

Only for dynamic state from API calls, complex logic, or when the state cannot be expressed through native HTML elements (checkbox, input).

11. FAQ: Tailwind group and peer selectors

1Difference between group and peer?
group: parent to children (group-hover: on child elements). peer: sibling to the following sibling (peer-checked: on the target element).
2Why must the peer target come after the peer?
Based on the CSS ~ (adjacent sibling selector), which only selects forward. Backward selection is not possible in CSS.
3Resolving conflicts with nested groups?
Named groups: group/card, group/button. Children specifically target group-hover/card: or group-hover/button:.
4CSS-only accordion with peer-checked?
Checkbox peer sr-only before the content. Content: max-h-0 peer-checked:max-h-48 overflow-hidden transition-all. Label as the visible toggle. No JavaScript.
5What is group-has?
Reacts when the group element contains a specific descendant, based on CSS :has(). Enables forward selection across any nesting depth.
6Floating labels with Tailwind peer?
Input: peer + placeholder=" ". The label after it uses peer-focus:top-1.5 peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-1.5.
7When to use Alpine.js instead of group/peer?
When state comes from API calls, complex logic is needed, or native HTML states are not sufficient.
8Using group-hover with focus and active?
Yes: group-focus, group-active, group-focus-within, group-focus-visible. Every pseudo-class of the parent element is available as a group variant.
9Combining group and peer on the same element?
Yes. Use named groups/peers to keep responsibilities clearly separated and avoid unintended overlaps.
10Building a tab interface with peer-checked?
One radio input per tab: peer/tab1 sr-only. Tab content: hidden peer-checked/tab1:block. Label as the tab button. Always exactly one tab active.