Variants that read DOM state
Tailwind's data, aria, group, peer, and has variants style hover, validity, and library state by reading the DOM directly, so you reach for React state only when no selector can.
Four ordinary interactions you’ll build a hundred times: a disclosure chevron rotates when its panel opens; a field’s border turns red the moment its input goes invalid; a card reveals a “Delete” button on hover; a submit button dims while any field above it is invalid.
Each is a fact the browser already tracks. It knows hover and input validity; the open panel sets data-state="open" on itself; CSS can see that a form contains an invalid field. You could mirror each fact into React state kept in sync by a handler, but every copy can drift from the truth and costs a re-render.
This lesson teaches the variants that read those facts straight from the DOM, so the styling writes itself. The plain variants you met earlier, hover:, focus-visible:, checked:, and sm:, already work this way: a variant is a CSS selector wrapped around a utility, so hover:bg-primary is just &:hover { background: … } in disguise. Here you apply that model to richer state, both the state the DOM tracks on its own (:invalid, :has(), a parent’s hover) and the state your JSX stamps onto an element (data-*, aria-*).
So before reaching for state to drive a style, ask: can the DOM already tell me this? When it can, you write a variant, with no handler and no re-render. React state comes later in the course, on purpose, so this no-state path is your default and state stays the exception.
A variant is a selector around the utility
Section titled “A variant is a selector around the utility”A Tailwind variant compiles to a CSS selector that wraps the utility’s declaration. You’ve seen two shapes: hover:bg-primary guards the rule with a pseudo-class (&:hover { … }), and md:p-6 guards it with a media query (@media (width >= 48rem) { … }).
That generalizes. Anything a CSS selector can target, a variant can express: an attribute ([data-state="open"]), a browser-maintained pseudo-class (:invalid, :disabled), or a reach across the DOM to an ancestor, sibling, or descendant (:has()). Across every family in this lesson only the shape of the selector changes, so you read a prefix off its selector instead of memorizing a list.
Watch the selector grow across four cases. The top row is the class you write on a JSX tag; the bottom row is the CSS Tailwind emits.
The anchor you already know: hover: becomes the pseudo-class &:hover. The blue links the prefix to its selector; the utility rotate-180 and its rotate: 180deg declaration stay put.
Same move, new shape: data-[state=open]: becomes the attribute selector &[data-state="open"]. Only the selector changed.
Now the selector reaches up: group-hover: becomes .group:hover &, styling this element when an ancestor marked group is hovered.
And it reaches down: has-[:invalid]: becomes &:has(:invalid), styling the element from a descendant it contains. Four cases, one idea: a selector wrapped around the utility.
Steps 2 through 4 are the three things you’re here to learn, and they’re the same move: a selector wrapped around a utility, reading some state. Once you read data-[state=open]: as the rule &[data-state="open"], you’re writing CSS selectors with a shorter syntax.
The families differ by one question: whose state does the selector read? It has five answers, and they organize the rest of the lesson. A selector can read your own element’s state, reach to a parent, a sibling, or a descendant, or look at where the element sits among its siblings.
One family per question: is the state on me (pseudo-classes, data-*, aria-*), on a parent (group-*), an earlier sibling (peer-*), a descendant (has-*), or about my position among siblings (first:, last:, odd:)? The boxes nest the way the relationships do: the parent contains this element, which contains the descendant.
We’ll walk these by how far they reach: your own attributes first, then parent, sibling, descendant, and position. Each family is the same idea with a wider selector.
Styling by data-* attributes
Section titled “Styling by data-* attributes”You met data-* attributes in the JSX and HTML semantics chapter: arbitrary, script-readable values you write straight into the JSX, like data-state or data-variant. What’s new here is reading one back as a style.
The variant form is data-[attr=value]:utility. It compiles to an attribute selector: data-[state=open]:rotate-180 becomes &[data-state="open"] { rotate: 180deg }. There’s also a presence form with no =value: data-loading:opacity-50 matches whenever the data-loading attribute is present at all, like [data-loading] in plain CSS.
This pays off in three situations.
A library sets the state. The common case. Many component libraries (including shadcn, which you’ll build a UI from in a later chapter) stamp a data-state attribute on their elements and flip its value as the element changes, such as open/closed for a disclosure. You don’t set it; you style by it. The usual example is a disclosure chevron that points right when closed and rotates a half-turn down when open:
<svg className="size-4 transition-transform data-[state=open]:rotate-180 motion-reduce:transition-none"> {/* chevron path */}</svg>You write data-[state=open]: because that’s the attribute the library promised to set; data-state is its convention, not Tailwind’s. The transition-transform makes the rotation glide rather than snap, and motion-reduce:transition-none turns it off for visitors who asked their OS for reduced motion, a habit worth keeping on anything that moves.
Here the attribute and the variant live in one file: a minimal disclosure where both button and panel carry data-state.
<div className="rounded-lg border border-border"> <button type="button" data-state="open" className="flex w-full items-center justify-between p-4 focus-visible:ring-2 focus-visible:ring-ring" > Billing details <ChevronDownIcon className="size-4 transition-transform data-[state=open]:rotate-180 motion-reduce:transition-none" /> </button> <div data-state="open" className="hidden p-4 data-[state=open]:block"> Your plan renews on the 1st. </div></div>The state lives on the element as an attribute. It’s hard-coded here so we can focus on styling; in real code a library or your own code would flip it between open and closed.
<div className="rounded-lg border border-border"> <button type="button" data-state="open" className="flex w-full items-center justify-between p-4 focus-visible:ring-2 focus-visible:ring-ring" > Billing details <ChevronDownIcon className="size-4 transition-transform data-[state=open]:rotate-180 motion-reduce:transition-none" /> </button> <div data-state="open" className="hidden p-4 data-[state=open]:block"> Your plan renews on the 1st. </div></div>The chevron reads its own data-state and rotates a half-turn when it’s open.
<div className="rounded-lg border border-border"> <button type="button" data-state="open" className="flex w-full items-center justify-between p-4 focus-visible:ring-2 focus-visible:ring-ring" > Billing details <ChevronDownIcon className="size-4 transition-transform data-[state=open]:rotate-180 motion-reduce:transition-none" /> </button> <div data-state="open" className="hidden p-4 data-[state=open]:block"> Your plan renews on the 1st. </div></div>A transition makes the transform glide, switched off under reduced motion.
<div className="rounded-lg border border-border"> <button type="button" data-state="open" className="flex w-full items-center justify-between p-4 focus-visible:ring-2 focus-visible:ring-ring" > Billing details <ChevronDownIcon className="size-4 transition-transform data-[state=open]:rotate-180 motion-reduce:transition-none" /> </button> <div data-state="open" className="hidden p-4 data-[state=open]:block"> Your plan renews on the 1st. </div></div>The panel does the same trick: hidden by default, block when open. No useState decides any of this.
Your own code sets the state. When a value in your app needs to drive a style, do the same on your own elements: set a data-* attribute from the value, then style by it. A data-loading flag dims and disables interaction while a request is in flight (data-loading:opacity-50 data-loading:pointer-events-none); a data-variant="ghost" selects a visual treatment (data-[variant=ghost]:bg-transparent). You bridge the value to an attribute instead of threading a conditional class through your component.
A toggle sets the state on a container. The same pattern scales to whole-page state. A data-theme="dark" on the <html> element is how theme toggling works, and any style can key off it, as the next two lessons show.
Now try it. The chevron below sits on a button that hard-codes data-state="open": no React, no handler, no toggle. Add the variant so it rotates to match the target, plus a transition so the change isn’t instant.
The button hard-codes data-state="open" — nothing toggles it. Add a class to the chevron so it rotates a half-turn to match the target. No state, no handler: the attribute is already there for you to read.
The style changed without a handler, because the DOM said so. That’s the pattern the whole lesson turns on.
Disclosure widget is the umbrella term here, and data-state is how nearly every one exposes its open/closed state to your styles.
Styling by aria-* attributes
Section titled “Styling by aria-* attributes”aria-* attributes are the sibling of data-*: written into the JSX and readable by a variant, but with a second job. An aria-* attribute describes an element’s role or state to assistive technology ; it’s part of ARIA . Styling off it means one attribute serves two audiences: a screen reader announces it, Tailwind styles by it, and the attribute is the single source of truth. When a nav link carries aria-current="page", that one attribute is both the announced state and the styling hook, so you style off it instead of tracking a separate flag by hand.
There are two ways to write an ARIA variant, and the difference is easy to get wrong.
Built-in shorthands exist for the boolean ARIA attributes, each targeting the ="true" case: aria-busy:, aria-checked:, aria-disabled:, aria-expanded:, aria-hidden:, aria-pressed:, aria-readonly:, aria-required:, aria-selected:. So aria-expanded:rotate-180 compiles to &[aria-expanded="true"] { … } and applies only when the attribute is the string "true". (ARIA values are always strings, never a JavaScript true; for the false case you write aria-[expanded=false]:.)
The arbitrary form aria-[attr=value]: covers everything else. The trap is that aria-current and aria-invalid have no shorthand: aria-current:font-semibold compiles to nothing, with no error and no style. You write aria-[current=page]:font-semibold and aria-[invalid=true]:border-destructive instead. aria-current has no shorthand because it isn’t a boolean: its value can be page, step, location, date, time, or true, so there’s no single case to target.
The two canonical shapes sit side by side below. Note that the nav link must use the arbitrary form.
<a href="/billing" aria-current="page" className="text-muted-foreground aria-[current=page]:font-semibold aria-[current=page]:text-foreground"> Billing</a>The arbitrary form is required: aria-current takes a value, so aria-current: would emit nothing. The one attribute does double duty: assistive tech announces “current page,” and the link styles itself bold.
<input type="email" aria-invalid="true" className="border border-input aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive/20"/>aria-invalid likewise has no shorthand, so aria-[invalid=true]: is the form. Set it when a field fails validation: the border and ring turn destructive while screen readers announce the field as invalid. The next lesson builds the form pattern on this shape.
We’re only covering the styling side of ARIA here; roles, live regions, and when to reach for ARIA at all come in a later chapter on shadcn primitives. The point for now is narrow: when an element already carries an aria-* attribute for accessibility, that attribute is free to style off, and your visuals can’t disagree with what’s announced.
Reading a parent’s state with group
Section titled “Reading a parent’s state with group”So far a variant has read state on the element itself. A child can also read its parent’s state. Mark the parent with the group utility, and any descendant reads it with a group- prefixed variant: group-hover:, group-focus:, and the attribute forms like group-data-[state=open]:. The variant reaches up the tree. group-hover:opacity-100 compiles to .group:hover & { opacity: 1 }: when an ancestor with class group is hovered, set this element’s opacity to 1.
The pattern you’ll reach for constantly is reveal-on-hover: an action button that appears only while the user engages with its card. The button starts invisible and fades in when the card, not the button, is hovered:
<article className="group rounded-lg border border-border p-4"> <h3 className="font-medium">Quarterly report</h3> <button className="opacity-0 transition-opacity group-hover:opacity-100"> Delete </button></article>Hover anywhere on the card, the title, the padding, or the button, and the button fades in, because the variant watches the ancestor. With state this would take an onMouseEnter, an onMouseLeave, a boolean, and a re-render on every entry and exit. Here it’s two utilities and no JavaScript.
Nesting groups creates one snag. Inside a group that contains another group, a plain group-hover: reads the nearest marked ancestor, which may not be the one you meant. Name the group to bind the variant to a specific ancestor: tag the parent group/card, read it with group-hover/card:.
<article className="group/card ..."> <button className="opacity-0 group-hover/card:opacity-100">Delete</button></article>Now try it. The card below holds a title and a fully transparent “Delete” button. Mark the card a group, and give the button a class that brings it to full opacity on card hover.
Hovering anywhere on this card should reveal its "Delete" button. Mark the card as a group, then give the button a class that brings it from invisible to full opacity when the card is hovered. The button starts at opacity-0 — no state, no handler, the card's hover does the work.
Reading a sibling’s state with peer
Section titled “Reading a sibling’s state with peer”A variant can also read a sibling, with one constraint on direction. Mark an element peer, and a later sibling reads its state with a peer- variant: peer-invalid:, peer-checked:, peer-focus:, peer-placeholder-shown:, peer-disabled:. The rule: a peer reader only sees a peer source that comes before it in the markup. This is a CSS limitation, not a Tailwind choice: the variant compiles to the subsequent-sibling combinator ~, which only looks forward, so the reader must appear after the source in your JSX.
That constraint fits one pattern perfectly: a native inline form error with no JavaScript. The browser keeps an :invalid pseudo-class on form fields based on their constraints, so a required field with no value is :invalid, and a type="email" field with junk in it is :invalid. Mark the input peer, place the error message after it, and let the message read the input’s validity.
<input type="email" required placeholder="you@example.com" className="peer border border-input"/><p className="hidden text-sm text-destructive peer-invalid:block"> Enter a valid email address.</p>Mark the field a peer so a later sibling can read its state.
<input type="email" required placeholder="you@example.com" className="peer border border-input"/><p className="hidden text-sm text-destructive peer-invalid:block"> Enter a valid email address.</p>These are what make :invalid meaningful: the browser’s Constraint Validation sets :invalid when the field is empty or holds an invalid email. No JS computes validity.
<input type="email" required placeholder="you@example.com" className="peer border border-input"/><p className="hidden text-sm text-destructive peer-invalid:block"> Enter a valid email address.</p>The message is hidden by default and shown only while the peer is :invalid: the browser sets the state, the sibling reads it, and the message appears.
<input type="email" required placeholder="you@example.com" className="peer border border-input"/><p className="hidden text-sm text-destructive peer-invalid:block"> Enter a valid email address.</p>Source order is load-bearing: the message reads the field, so it must come after the field. peer only reaches forward.
The same forward-reading trick drives the float-label pattern with peer-placeholder-shown:: a label sits inside an empty field and floats up once the user types, because an empty field with a placeholder matches :placeholder-shown and a filled one doesn’t.
Named peers work like named groups when more than one sits on a row: mark peer/email, read peer-invalid/email:.
That :invalid example is your first taste of Constraint Validation , the browser validating form fields for free from their HTML attributes. The forms chapters cover it in full; here it’s enough that :invalid exists and that peer- and has- read it.
Reading descendants with has-
Section titled “Reading descendants with has-”has-[…]: wraps the CSS :has() selector, which lets a parent style itself based on what it contains. This is the one selector that flows upward, from a descendant to its ancestor, and it removes a large class of state-mirroring: the parent no longer needs telling what changed inside it, because it reads the change itself. The bracket takes a full selector: has-[:invalid]:, has-[:checked]:, has-[[data-state=open]]:, has-[a]:.
Three patterns cover most of what you’ll do with it.
A form that highlights when any field inside it is invalid, the “submit area dims” interaction from the start of the lesson, in a single class:
<form className="rounded-lg border border-border has-[:invalid]:border-destructive"> {/* fields */}</form>The border turns destructive whenever the form contains even one :invalid descendant and reverts the moment the last is fixed. No state aggregates the fields; the form reads them.
A label that highlights when it contains a checked input, the radio-card pattern, where clicking anywhere in a card selects its radio and lights up the whole card:
<label className="rounded-lg border border-border p-4 has-[:checked]:border-primary has-[:checked]:bg-accent"> <input type="radio" name="plan" /> Pro plan</label>A list item that bolds when its link is the current page, reusing the aria-current attribute from earlier. Since there’s no aria-current shorthand, the attribute selector goes inside the brackets:
<li className="has-[[aria-current=page]]:font-semibold"> <a href="/billing" aria-current="page">Billing</a></li>The radio-card exercise shows DOM state driving the UI on its own. Add classes so the card holding the checked radio highlights, then click between the radios.
Each card wraps a radio input. Add two classes to every label so the card holding the *checked* radio gets an indigo border (has-[:checked]:border-indigo-600) and a faint indigo fill (has-[:checked]:bg-indigo-50). Then click between the radios and watch the highlight follow — the radios are native, so nothing you wrote runs on the click; each card reads its own contents.
Direct children, negation, and positional variants
Section titled “Direct children, negation, and positional variants”Three smaller families finish the set. Aim to recognize them, not memorize them.
Direct children with *:. *: styles every direct child of an element: *:py-2 adds vertical padding to each immediate child. Use it for children you don’t control as components, such as a slot of unknown content or a list of arbitrary elements handed to you. When you own the children, style them directly.
Negation with not-. not- flips a variant to “every element not in this state”: not-disabled:, not-first:, not-data-[state=open]:. The everyday use is dividers: not-last:border-b puts a bottom border on every row except the last, so rows get separators with no trailing line. (Deeper :not() selectors come in a later chapter on styling at depth.)
Positional variants. first:, last:, odd:, even:, only:, empty: read the element’s position among its siblings. They round the outer corners of a grouped list (first:rounded-t-lg last:rounded-b-lg), stripe alternating rows (odd:bg-muted), or collapse a container with no children (empty:hidden).
One realistic list exercises four at once: a settings list with rounded outer corners, alternating shading, a divider beneath every row but the last, and a container that disappears when empty.
<ul className="rounded-lg border border-border empty:hidden"> <li className="p-4 odd:bg-muted not-last:border-b border-border first:rounded-t-lg last:rounded-b-lg"> Profile </li> <li className="p-4 odd:bg-muted not-last:border-b border-border first:rounded-t-lg last:rounded-b-lg"> Notifications </li> <li className="p-4 odd:bg-muted not-last:border-b border-border first:rounded-t-lg last:rounded-b-lg"> Billing </li></ul>Every row carries identical classes; each variant applies based on where the row falls.
One more for recognition: open: reads the native open attribute on a <details> or <dialog>, so open:rounded-b-none restyles a <details> while it’s expanded. It’s rare in modern web apps, since richer disclosures reach for a library like Radix (a later chapter), but it’s there when a plain <details> suffices.
Stacking variants: constraint outermost
Section titled “Stacking variants: constraint outermost”Variants compose. Stack as many as you need on one utility, left to right, following one convention that keeps long chains readable: constraint outermost. Put the broadest gate, a breakpoint or theme, leftmost, and the specific state innermost. Read left to right, md:group-hover:dark:bg-accent says “at md and up, when the group is hovered, in dark mode, set the accent background.” The chain runs broad to narrow.
A few stacked combinations from the families you just met:
group-data-[state=open]:rotate-180: rotate when the parent group’sdata-stateisopen.peer-focus:not-disabled:text-foreground: when the peer is focused and this element isn’t disabled, darken the text.md:has-[:invalid]:border-destructive: atmdand up, when a descendant is invalid, show the destructive border.
Each prefix is one selector wrapper, applied in order, narrowing the condition as you go.
Now assemble a few yourself. Each blank below is a stacked variant for the scenario in the comment. Pick the prefix that builds the right condition.
Each blank is a stack of variants. Read the comment, then build the condition it describes — broadest gate first. Pick the right option from each dropdown, then press Check.
{/* rotate the chevron when the parent group is open */}<ChevronDownIcon className="size-4 transition-transform ___rotate-180" />
{/* red border at md and up when a descendant is invalid */}<form className="border ___border-destructive">{/* fields */}</form>
{/* bold only when not disabled and focused */}<button className="___font-semibold">Save</button>Choose a variant or React state
Section titled “Choose a variant or React state”Every state-driven style starts with one question: can the DOM already tell me this? If the change is driven by hover, focus, validity, a checked input, a disabled control, a library’s data-state, an ARIA attribute, or a parent, sibling, or descendant’s state, write a variant: no state, no handler, no re-render. Only when the value comes from the server, or is computed or derived so that no selector could match on it, reach for React state, styled with a conditional class composed through the cn() helper from earlier this chapter.
Variants don’t replace state in general; they replace the state that was only ever mirroring a DOM fact. A wizard’s current step or a banner that appears after data loads is real state, because no selector can read it. The skill is telling the two apart, and the question does the sorting.
Start from what drives the style change and let each answer narrow toward the tool. useState is the last branch, the one you reach only when every “can the DOM tell me?” answer was no.
hover:, focus-visible:, checked:, disabled:, or :invalid via its variant. The browser maintains the state; you just style it. No state, no handler.
data-[state=open]: for data-*. For ARIA, an aria-* shorthand on the boolean attributes, or the arbitrary aria-[current=page]: form where there’s no shorthand. The attribute is the single source of truth.
Mark the parent group; the child reads group-hover:, group-data-[state=open]:, and the rest. The selector reaches up the tree.
Mark the earlier sibling peer; a later sibling reads it with peer-invalid:, peer-checked:, … Source order matters, since peer only reaches forward.
The parent reads its own descendants with :has(): has-[:invalid]:, has-[:checked]:. It’s the one selector that reaches downward, and the one that removes the most state-mirroring.
The DOM can’t express it, so this is the considered exception, not the default: React state, styled with a class composed through cn(). You reach this branch only when every “can the DOM tell me?” answer above was no. (React state lands later in the course.)
That React state comes later is deliberate: learning the no-state path first makes it your default, so state arrives as the exception you reach for with a reason.
Now run the question yourself. Sort each interaction into the DOM already knows (write a variant) or needs React state (the fact lives outside the DOM).
Sort each interaction by whether the DOM can already tell you the answer — write a variant — or whether you'd have to track it in React state. Drag each item into the bucket it belongs to, then press Check.
External resources
Section titled “External resources”The canonical Tailwind reference for every variant family in this lesson — data-*, aria-*, group, peer, has, and positional.
Authoritative reference for the one selector that lets a parent react to its descendants, with syntax and browser support.
Worked examples that build intuition for the parent-reads-descendant pattern beyond the reference docs.
Why aria-current has no boolean shorthand — its values (page, step, location, …) and what each announces to assistive tech.