group-* vs peer-* — the two state channels
Both are ways to style one element based on another element's state
— without JavaScript. group-* reaches
downward: an ancestor carries group and any descendant
can read its state. peer-* reaches
forward: the trigger carries peer and only a LATER
sibling can read it. The difference is just the CSS combinator Tailwind emits.
| aspect | group-* (ancestor → descendant) | peer-* (sibling → sibling) |
|---|---|---|
| who wears the marker | the ancestor: class="group" |
the trigger itself: class="peer" |
| who reads the state | ANY descendant, at any depth | only a following sibling sharing the same parent |
| compiled CSS combinator | .group:hover .group-hover\:x (descendant ␣) |
.peer:checked ~ .peer-checked\:x (general sibling ~) |
| DOM direction | downward only (parent → child, any level) | forward only (older → younger sibling) |
| named form | group/card → group-hover/card: |
peer/toggle → peer-checked/toggle: |
| combine | group-hover:peer-checked:bg-red-500 — parent hovered AND sibling checked |
|
1 · hover the group / check the peer — both react live
Left: hover the card (or tab to its button) — the icon scales, the
title brightens, an underline appears, and a CTA slides in. All children
react to the SAME ancestor via group-hover: /
group-focus-within:. Right: check the box — the label
turns green, a panel slides open, and hidden text appears, all via
peer-checked: driven by the checkbox (a previous sibling).
group · hover me
Lightning Card
all children react at once
The .group ancestor is the single source of state.
Every child that uses group-hover: responds together —
no per-element event listeners.
peer · check the box
class="peer" — is now :checked. CSS alone drives this:
the label turned green, this panel slid in, and hidden text appeared below. No JavaScript.
group-cta opacity: — · peer-label bg: — · peer-panel max-h: —
2 · named groups/peers, stacking & the state menu
When groups/peers nest, name them to avoid ambiguity:
group/card + group-hover/card: targets that
specific ancestor only. Variants stack left-to-right as AND conditions.
| pattern | writes as | meaning |
|---|---|---|
| named group | <div class="group/card"> … group-hover/card:opacity-100 |
react only when the card ancestor (not an outer group) is hovered |
| named peer | <input class="peer/toggle"> … peer-checked/toggle:block |
react only to the toggle sibling (not a different peer in the same parent) |
| stacked (AND) | group-hover:peer-checked:bg-red-500 |
parent hovered AND the sibling checkbox checked |
| group + peer | group-focus-within:peer-invalid:border-red-500 |
ancestor has focus-within AND preceding sibling's input is invalid |
| arbitrary state | group-data-[state=open]:block · peer-[:placeholder-shown]:opacity-50 |
data-* attribute or any pseudo-class/attribute on the trigger |
Common states available as group-* / peer-*:
| state | variant | fires when the group/peer… |
|---|---|---|
| hover | group-hover: / peer-hover: | is pointed at |
| focus | group-focus: / peer-focus: | itself receives focus |
| focus-within | group-focus-within: / peer-focus-within: | or a descendant receives focus (keyboard-friendly hover proxy) |
| checked | group-checked: / peer-checked: | checkbox/radio is checked |
| disabled | group-disabled: / peer-disabled: | form control is disabled |
| required / valid / invalid | peer-required: / peer-valid: / peer-invalid: | form validation state changes |
| placeholder-shown | peer-placeholder-shown: | input still shows its placeholder (empty) |
| data-* | group-data-[size=lg]: | carries a matching data attribute |
intent → pattern
| intent | pattern | why |
|---|---|---|
| style children from a parent's hover | <div class="group"> + child group-hover:underline |
one ancestor = single source of state; many children react together |
| show a panel when a box is ticked | <input class="peer"> + later sibling peer-checked:block |
pure-CSS disclosure — no JS, no details element |
| make hover keyboard-accessible too | add group-focus-within: alongside group-hover: |
keyboard users who tab in see the same affordance |
| scope state to one nested group | group/card → group-hover/card:opacity-100 |
avoids an outer group hijacking the inner card's children |
| combine two conditions | group-hover:peer-checked:bg-red-500 |
variants stack as AND — read left-to-right |
| react to ANY custom state | group-data-[open=true]: / peer-[:checked]: |
data attributes + arbitrary pseudo-classes both work |