Skip to content
Chapter 18Lesson 4

Variants that read DOM state

Tailwind's data, aria, group, peer, and has variants let you style hover, validity, and library state by reading the DOM directly, so you reach for React state only when no selector can.

Picture four small interactions, each one ordinary, each one a thing you’ll build a hundred times. A disclosure widget’s chevron rotates a half-turn when its panel opens. An inline error message turns a field’s border red the moment the input goes invalid. A card lifts and reveals a hidden “Delete” button when you hover it. A submit button at the bottom of a form dims while any field above it is still invalid.

Once React is in your hands, the instinct is to wire each of these with state: a useState(false) for whether the panel is open, an onMouseEnter/onMouseLeave pair to track the hover, one piece of state for whether a field is valid, another for whether the whole form can submit. That’s four facts, four pieces of state, and four handlers keeping them in sync. But every one of those facts is something the browser already knows. The browser tracks hover, and it tracks input validity. The open panel sets data-state="open" on itself. The form can see, through CSS, that it contains an invalid field. Wiring these with state means copying facts out of the DOM and into React, where they can drift from the truth and where they cost a re-render every time they change.

This lesson teaches the variants that read those facts directly, so the styling writes itself. You already met the plain variants earlier in this chapter, hover:, focus-visible:, checked:, and sm:, along with the model that makes them work: a variant is a CSS selector wrapped around a utility, so hover:bg-primary is just &:hover { background: … } in disguise. That same model drives everything here. We’re going to apply it to richer kinds of state: state the DOM tracks on its own (:invalid, :has(), a parent’s hover) and state your JSX stamps onto an element (data-*, aria-*).

The habit this lesson builds is one to keep before you ever write your first useState. Before reaching for state to drive a style, ask: can the DOM already tell me this? If it can, you write a variant: no state, no handler, no re-render. React state comes later in the course, deliberately after this lesson, so that the no-state path is your default and state becomes the considered exception. By the end you’ll be able to style any state-driven UI change by picking the right variant family, and you’ll know the question that tells you which family you need.

A variant is a selector around the utility

Section titled “A variant is a selector around the utility”

Let’s make the model precise, since the rest of the lesson builds on it. A Tailwind variant compiles to a CSS selector that wraps the utility’s declaration. You’ve seen the two canonical shapes:

  • hover:bg-primary becomes a rule guarded by a pseudo-class: &:hover { background-color: … }.
  • md:p-6 becomes a rule guarded by a media query: @media (width >= 48rem) { … }.

Here’s the generalization that turns five prefixes into one idea. If a variant is any selector wrapped around a utility, then anything a CSS selector can target, a variant can express. A CSS selector can match on an attribute ([data-state="open"]). It can match a pseudo-class the browser maintains for you (:invalid, :disabled). It can reach across the DOM to an ancestor, to a sibling, or into descendants (:has()). Every variant family in this lesson is the same selector-wrapper idea; the only thing that changes from one family to the next is the shape of the selector. Once you see that, you can read a prefix off its selector instead of memorizing a list.

Watch the selector grow across four cases. In the following sequence, the top row is the class you write on a JSX tag and the bottom row is the CSS rule Tailwind emits for it. The first step is the one you already know; each step after it widens the selector a little further.

You write
hover:rotate-180
Tailwind emits
&:hover { rotate: 180deg }

The anchor, the variant you already know. hover: becomes the pseudo-class &:hover. The blue links the prefix to the selector it emits; the utility rotate-180 and its rotate: 180deg declaration are untouched.

You write
data-[state=open]:rotate-180
Tailwind emits
&[data-state="open"] { rotate: 180deg }

A new shape using the same move, an attribute selector. data-[state=open]: becomes &[data-state="open"], where the bracket carries the attribute and its value. Only the selector changed.

You write
group-hover:opacity-100
Tailwind emits
.group:hover & { opacity: 1 }

The selector now reaches up. group-hover: becomes .group:hover &, which matches when an ancestor marked group is hovered, styling this element from a parent’s state.

You write
has-[:invalid]:border-destructive
Tailwind emits
&:has(:invalid) { border-color: … }

And the selector can reach down. has-[:invalid]: becomes &:has(:invalid), where the element styles itself based on a descendant it contains. All four are the same idea: a selector wrapped around the utility.

That sequence is worth keeping in mind. Steps 2 through 4 are the three things you’re here to learn, and they’re all the same move: a selector wrapped around a utility, reading some state. Once you read data-[state=open]: as “the rule &[data-state="open"],” there’s nothing left to memorize; you’re just writing CSS selectors with a shorter syntax.

The families differ by one question: whose state does the selector read? That question organizes the rest of the lesson, and it has five answers. A selector can read your own element’s state, reach to a parent, reach to a sibling, reach into a descendant, or look at where the element sits among its siblings. Here’s the map; every prefix you’re about to meet hangs off one of these arrows.

parent
this element reads its parent’s state group-* ↑ UP
earlier sibling peer peer-* marked first in the markup
this element position first: last: odd:
reads its own state
hover: data-* aria-*
this element reads a descendant it contains ↓ DOWN
descendant a child inside has-*

Five questions, one per family: 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, and this element contains the descendant. Each section below takes one arrow.

We’ll walk these in order of how far they reach across the DOM: your own attributes first, then parent, sibling, descendant, and finally position. Every new family is the same idea with a wider selector, so none of them should take more than a moment to absorb.

We’ll start with the broadest self-attribute, the one you’ll reach for most. You met data-* attributes back in the JSX and HTML semantics chapter as a way to attach arbitrary, script-readable values to an element: data-state, data-variant, anything you like, written verbatim into the JSX. What’s new here isn’t the attribute, it’s reading it back as a style.

The variant form is data-[attr=value]:utility. It compiles to an attribute selector: data-[state=open]:rotate-180 is &[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, regardless of its value, the same way [data-loading] does in plain CSS.

There are three situations where this pays off, and they’re worth keeping distinct.

A library sets the state. This is the common case. Many component libraries, including the ones you’ll meet in a later chapter when you build a UI from shadcn primitives, stamp a data-state attribute on their elements and flip its value as the element changes: open/closed for a disclosure, active/inactive for a tab. You don’t set it; the library does, and your job is to 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>

When the library sets data-state="open" on this element, the selector matches and the rotation applies. The transition-transform makes it glide rather than snap. The motion-reduce:transition-none switches the transition off for visitors who’ve asked their OS for reduced motion, a habit worth keeping on anything that moves. Keep in mind that data-state is the library’s convention, not Tailwind’s and not a name you invent. You write data-[state=open]: because that’s the attribute the library promised to set.

To see the attribute and the variant living in the same file, here’s the markup for a minimal disclosure: a button that toggles a panel, where both 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. Here it’s hard-coded so we can focus on the 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>

Pair any state-driven transform with a transition so it glides, and switch the transition 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 to show and hide itself: hidden by default, block when open. No useState decides any of this.

1 / 1

Your own code sets the state. When a value in your app needs to drive a style, you can do the same thing on your own elements: set a data-* attribute from the value, then style by the attribute. 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). The approach is always the same: bridge the value to an attribute, style the attribute, and skip threading a conditional class through your component.

A toggle sets the state on a container. The same pattern scales up to whole-page state. A data-theme="dark" on the <html> element is how theme toggling works under the hood, and styles can key off it. That’s the subject of the next two lessons; for now, just note it as another instance of the same idea.

Now try it yourself. In the exercise below, a chevron sits on a button that hard-codes data-state="open". The attribute stays fixed on purpose: no React, no handler, no toggle. Add the variant to the chevron so it rotates to match the target on the right. You want a class that rotates 180 degrees when data-state is open, 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.

Target
Your output LIVE

You changed the style without wiring up a single handler. It changed because the DOM said so, which is the pattern the whole lesson turns on.

Disclosure widget is the umbrella term for this pattern, covering accordions, dropdowns, and collapsibles, and data-state is how nearly every one of them exposes its open/closed state to your styles.

aria-* attributes are the sibling of data-*: also written into the JSX, also 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 . The reason to prefer ARIA-driven styling is that an ARIA attribute is read by two audiences at once: a screen reader announces it, and Tailwind styles by it. That makes one attribute the single source of truth, with no second boolean in React state that could drift out of step with what assistive tech announces. 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 rather than track an isActive boolean by hand.

There are two ways to write an ARIA variant, and the difference between them is the one thing in this section that’s easy to get wrong, so it’s worth being precise about.

Built-in shorthands exist for the boolean ARIA attributes, and each one targets the ="true" case. The set is: aria-busy:, aria-checked:, aria-disabled:, aria-expanded:, aria-hidden:, aria-pressed:, aria-readonly:, aria-required:, aria-selected:. So aria-expanded:rotate-180 is shorthand for &[aria-expanded="true"] { … }, and it applies only when the attribute is the string "true".

The arbitrary form aria-[attr=value]: covers everything else: value-bearing attributes, and any attribute that doesn’t have a shorthand. The point that’s easy to miss is that aria-current and aria-invalid have no built-in shorthand. Having just learned aria-expanded:, it’s natural to assume aria-current: exists by analogy. It doesn’t, and aria-current:font-semibold compiles to nothing at all: no error, no style. You write the arbitrary form instead: aria-[current=page]:font-semibold and aria-[invalid=true]:border-destructive. aria-current has no shorthand because it isn’t really a boolean. Its value can be page, step, location, date, time, or true, so there’s no single case for a shorthand to target.

One more point: ARIA values are strings, not booleans. aria-expanded is the string "true" or "false", never a JavaScript true. The built-in aria-expanded: shorthand matches ="true" specifically; if you ever need to style the false case, that’s the explicit aria-[expanded=false]:. It’s an easy thing to trip on if you expect JavaScript truthiness to apply, because these are attribute strings rather than booleans.

The two canonical shapes sit side by side below. Notice 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 here. aria-current takes a value (page, step, …), so there’s no shorthand; aria-current: would emit nothing. The one attribute does double duty: assistive tech announces “current page,” and the link styles itself bold.

We’re only covering the styling side of ARIA here. The deeper story, covering roles, live regions, and when to reach for ARIA at all, comes in a later chapter, when you compose UIs from shadcn primitives. For now the point is narrow and useful: when an element already carries an aria-* attribute for accessibility, that attribute is free to style off, and styling off it means your visuals can never disagree with what’s announced.

So far the selector has read state on the element itself. Now it crosses the first boundary: a child reading its parent’s state.

The mechanic is a pair. Mark the parent element with the group utility, then any descendant can read the parent’s state with a group- prefixed variant: group-hover:, group-focus:, and the attribute forms too, group-data-[state=open]: and group-aria-expanded:. The selector reaches up the tree. group-hover:opacity-100 compiles to .group:hover & { opacity: 1 }, which reads “when an ancestor with class group is hovered, set this element’s opacity to 1.”

The pattern you’ll write constantly is reveal-on-hover. A card holds a title and an action button you only want visible when the user is engaging with the card. The button starts invisible and becomes visible 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>

The button reads the card’s hover. Hover anywhere on the card, whether the title, the padding, or the button itself, and the button fades in, because the selector is watching the ancestor, not the button. Doing this with state would mean an onMouseEnter and onMouseLeave on the card, a boolean, and a re-render on every entry and exit. Here it’s two utilities and zero JavaScript.

One sharp edge shows up when groups nest. If a group card contains another group element, a plain group-hover: on a deeply nested child reads the nearest ancestor marked group, which may not be the one you meant. The fix is named groups: tag the parent group/card and have the child read group-hover/card:, which binds the variant to that specific ancestor by name.

<article className="group/card ...">
<button className="opacity-0 group-hover/card:opacity-100">Delete</button>
</article>

Now try the reveal pattern yourself. The card below contains a title and a “Delete” button that starts fully transparent. Mark the card so it’s a group, and give the button a class that brings it to full opacity when the card is hovered, matching the target, which shows the button revealed.

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.

Target
Your output LIVE

The selector can read a sibling, too, and this is the one family with a hard rule about direction that you have to respect.

Mark an element with the peer utility, and a later sibling can read its state with a peer- variant: peer-invalid:, peer-checked:, peer-focus:, peer-placeholder-shown:, peer-disabled:. Here is the constraint to keep in mind: the peer reader only sees a peer source that comes before it in the markup. Source order matters. This isn’t a Tailwind choice but a CSS limitation: the variant compiles to the subsequent-sibling combinator ~, and ~ only looks forward. So if you mark an input peer, the element that reads it has to appear after the input in your JSX.

That constraint fits one pattern especially well: a native inline form error with no JavaScript at all. The browser maintains a :invalid pseudo-class on form fields based on their constraints. A required field with no value is :invalid, and a type="email" field with junk in it is :invalid. Mark the input peer, put the error message after it, and let the message read the input’s validity. As you step through it, watch for which pieces make :invalid meaningful and for the source order the whole thing depends on.

<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.

1 / 1

The same forward-reading trick drives the float-label pattern with peer-placeholder-shown:: a label that sits inside an empty field and floats up once the user starts typing, because an empty field with a placeholder matches :placeholder-shown and a filled one doesn’t.

Named peers work just like named groups when you have more than one 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 full treatment, including custom validity and the JavaScript API, comes much later in the forms chapters; here it’s enough to know that :invalid exists and that peer- and has- can read it.

This is the hardest direction to reach, and the most powerful: an element styling itself based on what it contains.

has-[…]: wraps the CSS :has() selector, and the key point is that :has() is the selector that finally lets a parent react to a child. For most of CSS’s history this was impossible, because selectors only ever flowed downward and forward, never up from a descendant. That gap is why so much “parent reacts to its contents” logic used to require JavaScript and state. With :has(), the parent reads its descendants directly. 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 or fieldset that highlights when any field inside it is invalid. This is the “submit area dims when any field is invalid” interaction from the start of the lesson, and it’s a single class:

<form className="rounded-lg border border-border has-[:invalid]:border-destructive">
{/* fields */}
</form>

The form’s border turns destructive whenever it contains even one :invalid descendant, and reverts the moment the last one is fixed. No state aggregates the validity of the fields; the form reads it.

A label or option row that highlights when it contains a checked input. This is the radio-card pattern, where clicking anywhere in a card selects its radio and the whole card lights up:

<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. Note the attribute selector nested inside the brackets, consistent with there being no aria-current shorthand:

<li className="has-[[aria-current=page]]:font-semibold">
<a href="/billing" aria-current="page">Billing</a>
</li>

This is where the “whose state?” progression pays off the most. has- removes a large class of state-mirroring entirely: the parent no longer needs to be told what changed inside it, because it reads the change itself.

The radio-card exercise shows clearly that DOM state alone can drive the UI. Below are three plan cards, each a <label> wrapping a radio input and its text. Add classes so that the card containing the checked radio highlights, with a colored border and a tinted background. Then click between the radios and watch the highlight follow. None of the code you wrote runs when you click; the DOM is doing all of it.

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.

Target
Your output LIVE

Direct-children, negation, and positional variants

Section titled “Direct-children, negation, and positional variants”

Three smaller families round out the set. The aim here is to recognize them when you see them, not to drill them.

Direct-children with *:. The *: variant styles every direct child of an element: *:py-2 puts vertical padding on each immediate child. Reach for it when you’re styling 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 do own the children, style them directly; *: is easy to over-apply and turn into a blunt instrument.

Negation with not-. not- flips a variant to “every element not in this state”: not-disabled:, not-first:, not-data-[state=open]:. It composes with the other families. The everyday use is dividers: not-last:border-b puts a bottom border on every row except the last, so you get separators between rows without a trailing line. (The :not() selector has more depth to it, covered in a later chapter on styling at depth.)

Positional variants. first:, last:, odd:, even:, only:, empty: read the element’s position among its siblings, the fifth and last arrow on the map. They’re how you 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 that has no children (empty:hidden).

Here’s a single realistic list that exercises four of these ideas at once. It’s a settings list where the outer corners are rounded, rows alternate shading, each row but the last has a divider beneath it, and the whole container 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>

Each row is identical; the variants sort out which row gets which treatment based on where it falls. first: rounds the top, last: rounds the bottom, odd: shades every other row, not-last:border-b draws the dividers.

One more for recognition: open: reads the native open attribute on a <details> or <dialog> element, so open:rounded-b-none would restyle a <details> while it’s expanded. It’s rare in modern SaaS, since richer disclosures reach for a library like Radix instead, which you’ll meet in a later chapter on shadcn and Radix primitives. But it’s there when a plain <details> is all you need.

Stacking variants and the order that reads best

Section titled “Stacking variants and the order that reads best”

Variants compose. You can stack as many as you need on one utility, left to right, and the order is your call. But there’s a convention that makes long chains readable, the same one you met when you first learned the prefix-and-colon grammar, now extended to the new families: constraint outermost. Put the broadest gate, a breakpoint or a 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 learned:

  • group-data-[state=open]:rotate-180: rotate when the parent group’s data-state is open.
  • 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: at md and up, when a descendant is invalid, show the destructive border.

The goal is to read a multi-prefix class with ease: each prefix is one selector wrapper, applied in order, narrowing the condition as you go.

Now assemble a few yourself. In the snippet below, each blank is a stacked variant for the scenario described 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>

Everything in this lesson has been building toward one habit. Here it is stated plainly, as a decision you can run on every state-driven style you write.

Every state-driven style change 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, you write a variant: no state, no handler, no re-render. If the value comes from the server instead, or is a computed or derived value that no selector could ever match on, then React state is the right tool, styled with a conditional class composed through the cn() helper you met earlier in this chapter. That’s the fallback path, not the default.

Be careful not to take this too far. Variants don’t replace state in general; they replace the state that was only ever mirroring a DOM fact in the first place. A wizard’s current step, a count derived from a search box, a banner that appears after data loads: those are real state, because no selector can read them. The skill is telling the two apart, and that’s exactly what the question sorts out.

The decision below is the part worth keeping once the exact prefixes fade. Start from what drives the style change and let each answer narrow toward the tool. Notice that useState is the last branch, the one you reach only when every “can the DOM tell me?” answer was no.

Read state, or mirror it?

That useState lands later in the course is deliberate. You’re learning the no-state path first, so it becomes your default, and state arrives as the exception you reach for with a reason.

Now run that question yourself. Sort each interaction below into the bucket that fits: the DOM already knows (write a variant) or needs React state (the fact lives outside the DOM). This is the judgment that ties the whole lesson together.

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.

DOM already knows Write a variant — no state.
Needs React state The fact isn't in the DOM.
A chevron rotates when an accordion opens
A card highlights on hover
An error message shows when an input is invalid
A nav link goes bold on the current page
A submit area dims when any field in the form is invalid
A banner appears after data loads from the server
A result count derived from a search box
A multi-step wizard’s current step

Read the buckets you sorted into “DOM already knows” again. Every one of them is a style you simply don’t write the machinery for: no boolean, no handler, no effect keeping things in sync. That’s what the question saves you, every time you remember to ask it. When you start pasting in real components in a later chapter and find them studded with data-[state=open]:, aria-[invalid=true]:, and peer-disabled:, you’ll read them for what they are: a parent, a sibling, or an element reading state that was already true.