Group, Peer & has() Variants
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.
<!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.
<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.
<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.
<!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-50applies when a checkbox or radio inside is checked.has-[:focus-visible]:ring-2applies when anything inside has keyboard focus.has-[img]:grid-cols-2applies when the element contains an image.not-has-checked:opacity-70applies when nothing inside is checked.
<!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 attribute | group on the parent, group-*: on the child |
| an earlier sibling is checked, focused or invalid | peer on the sibling, peer-*: on the element |
| something inside it is checked, focused or present | has-*: on the element itself |
Common mistakes
- Forgetting the marker class.
group-hover:does nothing unless an ancestor hasgroup;peer-checked:needs a sibling withpeer. - 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. Usesr-onlyso it stays usable. - Showing errors too early.
peer-invalid:shows the message before the user types;peer-user-invalid:waits for interaction.
Key points
groupon a parent lets children usegroup-hover:,group-focus-visible:,group-aria-expanded:and more.- Name nested groups with
group/nameand react withgroup-hover/name:. peeron a sibling lets later siblings usepeer-checked:,peer-focus:,peer-user-invalid:; the peer must come first.has-checked:,has-[img]:andhas-[: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
<!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?
group-* variants react to the nearest ancestor marked with the group class.
2. Why must a peer element come before the element that uses peer-checked:?
Tailwind uses the general sibling combinator ~, which only looks forward in the HTML.
3. Which variant styles a card when a radio button inside it is selected?
has-checked: uses :has(:checked) to match an element that contains a checked input.
4. Why is peer-user-invalid: often better than peer-invalid: for error messages?
:user-invalid waits until the user has changed the field, so errors do not appear on an untouched form.
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
