Group, Peer & has() Variants

Tailwind CSS · Lesson 16 of 27 7 min read Updated

The state variants in the previous lesson look at the element itself: is this button hovered? Real interfaces often need more. When you hover over a whole card, the title should change colour. When a checkbox is ticked, the switch next to it should slide. When a radio button inside a card is chosen, the whole card should light up.

Tailwind has three tools for this: group reacts to a parent, peer reacts to an earlier sibling, and has reacts to something inside. In this lesson you will build a hover card, an accordion, a toggle switch, a live validation message and a set of selectable payment cards, mostly without JavaScript.

group: style children from the parent's state

Mark the parent with the class group. Then any child can use group-hover:, group-focus-visible:, group-active: and similar variants, which apply when the parent is in that state.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Group hover</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="bg-slate-100 p-8">
  <a href="#" class="group block max-w-sm rounded-xl bg-white p-5 shadow-sm transition hover:shadow-lg focus-visible:outline-2 focus-visible:outline-sky-600">
    <p class="text-sm text-slate-500">Free workshop · Lucknow</p>
    <h3 class="mt-1 text-lg font-semibold text-slate-900 group-hover:text-sky-700">Build your first website in one day</h3>
    <p class="mt-3 flex items-center gap-1 text-sky-700">
      Register now
      <span aria-hidden="true" class="transition group-hover:translate-x-1">→</span>
    </p>
  </a>
</body>
</html>

Hover anywhere on the card. The title changes colour and the arrow slides right, even though the pointer may not be over either of them. The whole card is one link, so it is also one large, easy target to tap.

group also works with ARIA and data attributes. A common use is the arrow on an accordion button: the button's aria-expanded attribute rotates the icon inside it.

HTML
<button class="group flex w-full justify-between" aria-expanded="false">
  Fee structure
  <span aria-hidden="true" class="transition group-aria-expanded:rotate-180">▾</span>
</button>

Named groups

Groups can be nested, for example a list of courses where each row is a group, inside a card that is also a group. A plain group-hover: then reacts to the nearest group. To pick a specific one, name it with a slash: group/row on the parent and group-hover/row: on the child.

HTML
<ul class="group/list">
  <li class="group/row flex justify-between p-2 hover:bg-slate-50">
    Python basics
    <a href="#" class="invisible group-hover/row:visible">Edit</a>
  </li>
</ul>

The "Edit" link only appears on the row under the pointer, not on every row in the list.

peer: style an element from a sibling's state

Sometimes the element you need to react to is not a parent but a sibling, such as a checkbox next to a custom-styled label. Mark the sibling with peer, then use peer-checked:, peer-focus:, peer-invalid: and so on.

There is one rule: the peer must come before the element that reacts to it in the HTML. This comes from CSS itself; the sibling combinator ~ that Tailwind uses only looks forwards.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Peer switch and validation</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="flex max-w-sm flex-col gap-8 p-8">
  <label class="flex cursor-pointer items-center justify-between gap-4">
    <span>SMS alerts for train delays</span>
    <span class="relative inline-flex shrink-0">
      <input type="checkbox" class="peer sr-only">
      <span class="h-6 w-11 rounded-full bg-slate-300 transition peer-checked:bg-emerald-600 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-emerald-600"></span>
      <span class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm transition peer-checked:translate-x-5"></span>
    </span>
  </label>

  <div>
    <label for="email" class="block text-sm font-medium">Email</label>
    <input id="email" type="email" required placeholder="you@example.com" class="peer mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 user-invalid:border-red-500">
    <p class="mt-1 hidden text-sm text-red-600 peer-user-invalid:block">Please enter a valid email address.</p>
  </div>
</body>
</html>

Click the switch: the real checkbox is hidden with sr-only (still usable by keyboard and screen readers), and both the track and the knob come after it, so they can react through peer-checked:. The knob slides with translate-x-5. Then type "abc" in the email field and click outside it: the red message appears through peer-user-invalid:. The user-invalid state only turns on after the user has interacted with the field, so the error does not shout at people before they have typed anything.

Like groups, peers can be named: peer/email and peer-invalid/email: let an element react to one particular sibling when there are several.

has: style a parent from what it contains

The has-* variants use the CSS :has() selector (see :has(), :is() and :where()) to style an element based on its descendants. This removes the need for JavaScript in many components.

  • has-checked:bg-sky-50 applies when a checkbox or radio inside is checked.
  • has-[:focus-visible]:ring-2 applies when anything inside has keyboard focus.
  • has-[img]:grid-cols-2 applies when the element contains an image.
  • not-has-checked:opacity-70 applies when nothing inside is checked.
HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Selectable cards</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="p-8">
  <fieldset class="grid max-w-md gap-3">
    <legend class="mb-2 font-semibold">Choose a payment method</legend>
    <label class="flex cursor-pointer items-center gap-3 rounded-xl border border-slate-300 p-4 has-checked:border-sky-600 has-checked:bg-sky-50 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-sky-300">
      <input type="radio" name="pay" value="upi" class="accent-sky-600" checked>
      <span class="flex-1">UPI</span>
      <span class="text-sm text-slate-500">Instant</span>
    </label>
    <label class="flex cursor-pointer items-center gap-3 rounded-xl border border-slate-300 p-4 has-checked:border-sky-600 has-checked:bg-sky-50 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-sky-300">
      <input type="radio" name="pay" value="card" class="accent-sky-600">
      <span class="flex-1">Debit or credit card</span>
      <span class="text-sm text-slate-500">Visa, RuPay</span>
    </label>
    <label class="flex cursor-pointer items-center gap-3 rounded-xl border border-slate-300 p-4 has-checked:border-sky-600 has-checked:bg-sky-50 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-sky-300">
      <input type="radio" name="pay" value="cod" class="accent-sky-600">
      <span class="flex-1">Cash on delivery</span>
      <span class="text-sm text-slate-500">₹40 extra</span>
    </label>
  </fieldset>
</body>
</html>

Choose a different option, by clicking or with the arrow keys: the selected card's border and background change. The whole card is a <label>, so clicking anywhere on it selects the radio button.

has combines with group and peer too. group-has-checked:text-sky-700 styles a child when its group contains a checked input, and peer-has-checked: does the same for a peer.

Choosing the right tool

You want to style an element when...Use
a parent is hovered, focused or has an attributegroup on the parent, group-*: on the child
an earlier sibling is checked, focused or invalidpeer on the sibling, peer-*: on the element
something inside it is checked, focused or presenthas-*: on the element itself

Common mistakes

  • Forgetting the marker class. group-hover: does nothing unless an ancestor has group; peer-checked: needs a sibling with peer.
  • Putting the peer after the element. The peer must come first in the HTML, because CSS sibling selectors only look forwards.
  • Hiding a checkbox with hidden. That removes it for keyboard and screen-reader users too. Use sr-only so it stays usable.
  • Showing errors too early. peer-invalid: shows the message before the user types; peer-user-invalid: waits for interaction.

Key points

  • group on a parent lets children use group-hover:, group-focus-visible:, group-aria-expanded: and more.
  • Name nested groups with group/name and react with group-hover/name:.
  • peer on a sibling lets later siblings use peer-checked:, peer-focus:, peer-user-invalid:; the peer must come first.
  • has-checked:, has-[img]: and has-[:focus-visible]: style an element from what it contains.
  • These variants replace a lot of small JavaScript for switches, selectable cards and validation messages.

Exercise

Make a "Plan picker" for a gym in Pune with two plans, Monthly (₹1,500) and Yearly (₹12,000). Each plan is a label card containing a radio button. The chosen card must get an emerald border and a light emerald background using has-checked:. Inside each card, make the price text turn emerald only when that card's radio is checked, using a group and group-has-checked:.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Plan picker</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="p-8">
  <fieldset class="flex max-w-md gap-3">
    <legend class="mb-2 font-semibold">Choose a plan</legend>
    <label class="group flex flex-1 cursor-pointer flex-col gap-1 rounded-xl border border-slate-300 p-4 has-checked:border-emerald-600 has-checked:bg-emerald-50">
      <input type="radio" name="plan" value="monthly" class="accent-emerald-600">
      <span class="font-semibold">Monthly</span>
      <span class="text-slate-600 group-has-checked:font-bold group-has-checked:text-emerald-700">₹1,500</span>
    </label>
    <label class="group flex flex-1 cursor-pointer flex-col gap-1 rounded-xl border border-slate-300 p-4 has-checked:border-emerald-600 has-checked:bg-emerald-50">
      <input type="radio" name="plan" value="yearly" class="accent-emerald-600">
      <span class="font-semibold">Yearly</span>
      <span class="text-slate-600 group-has-checked:font-bold group-has-checked:text-emerald-700">₹12,000</span>
    </label>
  </fieldset>
</body>
</html>

has-checked: styles the card that contains the chosen radio, and because each card is also a group, the price inside uses group-has-checked: to react to the same state.

Quick Quiz

1. A child has group-hover:text-sky-700. What else is needed for it to work?

2. Why must a peer element come before the element that uses peer-checked:?

3. Which variant styles a card when a radio button inside it is selected?

4. Why is peer-user-invalid: often better than peer-invalid: for error messages?

Learn Tailwind CSS with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call