Advanced Variants

Tailwind CSS Intermediate/Advanced

Chapter 4 · Advanced Variants and Selectors

Tailwind CSS Fundamentals 6 covered hover, focus, groups and peers. A variant is a selector in disguise, and v4 turns most of the useful modern selectors (:has(), :not(), :nth-child(), @starting-style) into class prefixes. This chapter goes through the ones that change how you build interfaces, and for each one it shows the selector Tailwind generates and what a browser did with it, because several of them behave differently from what the name suggests.

Every example here was run
Tailwind CLI 4.3.3 and Chrome 154, driven by a script that sent real mouse movement and clicks and then read computed styles. Not tested: Firefox, Safari, touch devices (where it matters the text says so), and screen readers.

Named Groups and Peers

group lets a child react to an ancestor's state. The trap is nesting: group-hover: means "any ancestor with the class group is hovered". Naming the group (group/card) makes the link exact. I built a card containing an item, both groups, with three spans that react differently, and moved the pointer:

<div class="group/card group"> <!-- the card --> <div class="h-8">card only</div> <div class="group/item group"> <!-- the item inside it --> <span class="group-hover/card:text-red-500">card</span> <span class="group-hover/item:underline">item</span> <span class="group-hover:italic">plain</span> </div> </div>
group-hover/card: colour group-hover/item: underline group-hover: italic nothing hovered normal none normal hover card only RED none italic <- the unnamed one fires hover the inner item RED underline italic .group-hover\/card\:text-red-500:is(:where(.group\/card):hover *) .group-hover\/item\:underline:is(:where(.group\/item):hover *) .group-hover\:italic:is(:where(.group):hover *) (any ancestor named plain "group")

Peers work sideways: a sibling reacts to the state of an earlier sibling. They compile to the general-sibling combinator, which only looks forward in the markup. Measured with a text input marked peer/field and a paragraph on each side of it:

<p class="peer-focus/field:text-red-500">before</p> <input class="peer/field border"> <p class="peer-focus/field:text-red-500">after</p>
before focus: before normal after normal field focused: before normal after RED .peer-focus\/field\:text-red-500:is(:where(.peer\/field):focus ~ *)

The paragraph before the input never reacts: ~ cannot look backwards. If a label must appear above the input, put the input first in the HTML and reorder visually (flex order or a grid). The same /name suffix applies to group-has-*, group-data-* and peer-* variants.

has-*: A Parent That Reacts to Its Children

has-* wraps CSS :has(), which finally lets a parent change based on what is inside it. The shorthand variants take the state name; the bracket form takes any selector:

<label class="has-checked:bg-green-500 has-[input:disabled]:opacity-50"><input type="checkbox"> pick</label> .has-checked\:bg-green-500:has(:checked) .has-\[input\:disabled\]\:opacity-50:has(:is(input:disabled)) .has-\[\>img\]\:p-2:has( > img) (direct child only) .group-has-\[img\]\/card\:p-4:is(:where(.group\/card):has(:is(img)) *)
unchecked label bg: oklch(0.967 0.003 264.542) (gray-100) disabled-checkbox label opacity: 0.5 after clicking the checkbox, label bg: green-500

This replaces a lot of JavaScript: a selected radio card, a form row that highlights when a field inside it is invalid (has-[:invalid]), a card that changes layout when it contains an image (has-[img]). Use the bracket form with a simple selector; a long selector inside has-[…] is hard to read and a sign that a data attribute (below) would be clearer.

not-*: Negation

not- inverts another variant or accepts a selector. The generated selectors show what each one really means:

not-first:mt-2 .…:not(:first-child) not-[.skip]:underline .…:not(:is(.skip)) not-has-checked:bg-red-500 .…:not(:has(:checked)) not-md:hidden @media not (width >= 48rem) (below the md breakpoint) not-hover:underline .…:not(:hover) AND @media not (hover: hover) { … } not-supports-[display:grid]:block @supports not (display:grid) { … }
<li class="not-first:bg-blue-500 not-[.skip]:underline">1</li> bg none underline <li class="not-first:bg-blue-500 not-[.skip]:underline skip">2</li> bg blue-500 underline none

Notice not-hover:: it generates two rules, the plain :not(:hover) and a media query for devices that cannot hover at all. By reading the CSS, the second one means a touch-only device receives the not-hover styling permanently. I did not test that on a phone. If you use it, think about what that device should show.

in-*: A Group Without the Class

in-* is group-* without naming an ancestor: "some ancestor matches this". It is handy for state set by a library on an element you do not control (in-data-[state=open]:flex, in-[.dark]:text-white). But there is one very easy trap:

<div class="h-8 bg-gray-100"><span class="in-hover:text-red-500">in</span></div> .in-hover\:text-red-500 compiles to :where(:hover) .in-hover\:text-red-500
pointer never moved: span normal (body:hover false) pointer over an unrelated label: span RED (body:hover true, the span's own parent #inp:hover false)

"Some ancestor is hovered" includes body and html, so as soon as the pointer is anywhere over the page the span turns red, even though the pointer is nowhere near its parent. The same applies to in-focus: when focus is inside the page. Use in-* only with a specific ancestor (in-data-[…], in-[.class]), and use a named group for hover and focus.

nth-*: Position

Tailwind has first, last, odd, even, and the general nth-* family, which takes a number, a formula in brackets, or the last and of-type forms. I put these four on every item of a five-item list:

<li class="nth-3:bg-red-500 nth-[2n+1]:underline nth-last-2:italic odd:font-bold"> nth-3 :nth-child(3) nth-last-2 :nth-last-child(2) nth-[2n+1] :nth-child(2n+1) odd :nth-child(odd) nth-of-type-2 :nth-of-type(2)
li1 underline bold li2 li3 bg red-500 underline bold li4 italic li5 underline bold

nth-[2n+1] and odd match the same items (1, 3, 5); nth-last-2 counts from the end, so it is item 4 of 5. Prefer these over adding classes in a loop when the pattern is purely positional (striped rows, a different first item).

*: and **:: Styling Children From the Parent

*: applies a utility to every direct child and **: to every descendant. They are tempting for lists of plain items ("every child gets padding") and they have one sharp edge, which I measured with a parent containing a plain child and a child with its own classes:

<div class="*:p-2 *:bg-yellow-500 **:text-red-500"> <div>child<span>grandchild</span></div> <div class="p-8 bg-blue-500">own classes</div> </div> :is(.\*\:p-2 > *) and :is(.\*\*\:text-red-500 *)
plain child: padding 8px bg yellow-500 grandchild colour RED child with p-8 bg-blue-500: padding 8px bg yellow-500 colour RED

The second line is the lesson: the child's own p-8 and bg-blue-500 lost. The parent's rule has the same specificity (one class) as the child's utility and is written later in the stylesheet (in my build .p-8 was at line 188 and the *:p-2 rule at line 210), so it wins. The practical rules: use *: only for children that will never carry their own styling; do not mix it with per-child classes; and treat **: as a last resort, because it reaches every descendant including ones you add later and makes the markup hard to read (a reader looking at a child cannot see where its colour comes from).

Element State: open, inert and Popovers

Native elements keep their own state, and Tailwind has variants for it. These are the selectors:

open:bg-yellow-500 .…:is([open], :popover-open, :open) inert:opacity-50 .…:is([inert], [inert] *)
details with open:bg-yellow-500, closed bg transparent details after det.open = true bg yellow-500 popover with open:bg-green-500, hidden / shown white / green-500 <div inert class="inert:opacity-50"> opacity 0.5 (and a child with inert:underline: underline) popover-open:font-bold weight 400 shown -- no rule was generated for popover-open: at all

Three notes. open: in v4 covers [open] (details, and dialogs, which I did not test), a shown popover (tested) and the :open pseudo-class in one variant, so use it instead of popover-open:: I wrote popover-open:font-bold and Tailwind generated nothing for it (no error). inert: matches the element and everything inside it ([inert] *), which is what you want for dimming a section while a modal is open. And a test-harness caveat: my scripted click on the <summary> did not toggle the details element, so I set det.open = true from script instead; the variant responded to the attribute, but I cannot confirm the click-to-toggle path in that harness.

data-* and aria-*: State Machines in Attributes

The cleanest way to style a component with several states is not a pile of conditional classes but one attribute that holds the state, with classes that react to its values. Headless UI libraries work this way (they set data-state or aria-* attributes and leave the styling to you; chapter 6). The three forms:

VariantSelectorUse for
data-[state=active]:bg-blue-500[data-state="active"]an attribute with a specific value
data-active:underline[data-active]an attribute that is just present
aria-selected:font-bold[aria-selected="true"]accessibility state that is already on the element
group-data-[state=open]/menu:rotate-180…:is(:where(.group\/menu)[data-state="open"] *)a child reacting to a named ancestor's state
<button data-state="active" aria-selected="true" class="data-[state=active]:bg-blue-500 aria-selected:font-bold data-active:underline">One</button> <button data-state="inactive" aria-selected="false" class="data-[state=active]:bg-blue-500 aria-selected:font-bold data-active:underline">Two</button>
as loaded: tab1 bg blue-500 weight 700 underline none tab2 bg none weight 400 underline none after JavaScript sets data-state, aria-selected and data-active on tab2 (and resets tab1): tab1 bg none weight 400 tab2 bg blue-500 weight 700 underline underline menu chevron rotate: closed none -> data-state="open" on the group: 180deg

No class was added or removed: the script changed three attributes and the styles followed. aria-selected: is doubly useful, because the same attribute is what assistive technology reads, so the styling and the accessibility state cannot drift apart. Note that rotate-180 sets the CSS rotate property in v4, not transform: reading transform in the browser returned none in both states, which fooled me for a moment.

starting:: Entry Transitions Without JavaScript

A newly inserted element has no "before" state, so it cannot transition in. The CSS rule @starting-style supplies one, and starting: is its variant:

<div class="transition-opacity duration-500 opacity-100 starting:opacity-0">entering</div> @starting-style { .starting\:opacity-0 { opacity: 0; } }
opacity just after insertion: 0 two animation frames later: 0.00247 after 800 ms: 1

The element appeared at 0, was already fading in two frames later, and settled at 1, with no timer or class toggling. I tested an element inserted by script; dialogs and popovers use the same mechanism but I did not run them. It worked in Chrome 154; I did not test other browsers, so check your support target.

Direction: rtl: and ltr:

Two variants key on the text direction, and they pair with the logical utilities (ms-*, me-*, ps-*) that flip on their own:

<div dir="rtl"><p class="ms-4 ltr:text-left rtl:text-right">…</p></div> .rtl\:text-right:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)
dir="rtl": ms-4 -> margin-left 0px, margin-right 16px text-align right dir="ltr": ms-4 -> margin-left 16px, margin-right 0px text-align left

ms-4 (margin at the start) landed on the right in a right-to-left block without an rtl: class, which is why logical utilities are preferred and rtl: is for what has no logical form. The full treatment is in chapter 9.

Mistakes while preparing this chapter
1. My filter hid half of the output. I listed generated selectors with a pattern that kept lines starting with a dot, and concluded that *:, **: and in-hover: were not generated. They were: their selectors start with :is( or :where(. (popover-open: really was missing.) How to avoid it: when something seems absent, search the whole file for the class name before deciding.

2. A failed command left the page unedited. A quoting error stopped the shell before my page edit ran, and the next step failed with a confusing browser error. How to avoid it: after any failed command, check that its side effects happened before running the next step.

3. My first in-hover test measured the wrong thing. I read it after earlier clicks had left the pointer on the page, so a result labelled "pointer away" was red. That led me to the real behaviour (it is red whenever the pointer is on the page), and I rebuilt the test to start with a pointer that had never moved. How to avoid it: print the precondition (body:hover) next to the result.

4. I read the wrong property. rotate-180 sets rotate, not transform. How to avoid it: read the generated rule first, then choose what to measure.

Hands-On Exercises

Exercise 1

Build a card containing an item, both marked as named groups, with one span for each of group-hover/card:, group-hover/item: and the unnamed group-hover:. Move the pointer over nothing, the card only and the item, and record each span. Then add a paragraph before and after a peer/field input and show which one reacts when the input is focused.

📄 View solution
Exercise 2

Write a checkbox label with has-checked: and a disabled one with has-[input:disabled]:; a list using not-first: and not-[.skip]:; a five-item list with nth-3:, nth-[2n+1]:, nth-last-2: and odd:; and an in-hover: span. Record all results, and show with body:hover why in-hover: is not safe.

📄 View solution
Exercise 3

Use *:p-2, *:bg-yellow-500 and **:text-red-500 on a parent with one plain child and one child that has its own p-8 bg-blue-500, and find out who wins and why. Then build a two-tab strip driven by data-state, aria-selected and data-active, a chevron rotated by group-data-[state=open]/menu:, and an element that fades in with starting:.

📄 View solution

Chapter 4 Quick Reference

  • Name groups and peers (group/card, group-hover/card:, peer/field, peer-focus/field:); unnamed group-hover: matches any ancestor named group. Peers react only to earlier siblings
  • has-checked:, has-[input:disabled]:, has-[>img]:, group-has-[img]/card:: a parent reacting to its children (:has())
  • not-first:, not-[.skip]:, not-has-checked:, not-md: (below md), not-supports-[…]:; not-hover: also adds a no-hover-device media rule
  • in-* = some ancestor matches; in-hover: and in-focus: match as soon as the pointer or focus is anywhere on the page. Use in-data-[…] or in-[.class]
  • nth-3:, nth-[2n+1]:, nth-last-2:, nth-of-type-2:, odd:, even:, first:, last:
  • *: (children) and **: (descendants) beat the child's own utilities of equal specificity; avoid mixing them; **: is a last resort
  • open: = [open], :popover-open or :open; no popover-open: rule in 4.3.3; inert: also matches everything inside
  • data-[state=x]: (value), data-active: (present), aria-selected:, group-data-[state=open]/menu:: state lives in an attribute, styles follow
  • starting: = @starting-style: entry transitions for inserted elements (Chrome 154 tested)
  • rtl: / ltr: check dir; prefer logical utilities (ms-4)
Coming next
Chapter 5 takes everything so far into real projects: Vite, Astro, Next.js, SvelteKit, React Router and Vue, no-flash dark mode with server rendering, framework style blocks and @reference, and monorepo theme packages. Start with Tailwind CSS Intermediate/Advanced 5.