state in an attribute, not a class
Headless UI libraries don't toggle your classes for you — they write
data- attributes on the DOM to announce state
(Radix: data-state="active", Headless UI: data-open,
shadcn: data-[loading=true]). Tailwind v4's data-*
variants turn those attributes into styling hooks with zero glue code:
data-[state=active]:bg-cyan-600 compiles to the CSS attribute
selector [data-state="active"]. The browser already knew how to
match attribute selectors — Tailwind just hands you the syntax.
| variant | compiles to | fires when… |
|---|---|---|
data-[state=active]: |
[data-state="active"] |
exact value match — Radix tabs/dialogs |
data-[loading=true]: |
[data-loading="true"] |
explicit value match — shadcn async UI |
data-[open]: |
[data-open] |
presence check (any value, even "") — Headless UI disclosure |
data-[size=lg]: |
[data-size="lg"] |
custom attribute — design-system sizing tokens |
data-[:is(open)]: |
arbitrary selector mode | escape hatch — drop a raw selector inside the brackets |
Three shapes cover everything: presence (data-[open]:),
value match (data-[state=active]:), and
arbitrary selector (data-[…]: with a nested
:is() / :not()). All compile to plain CSS attribute
selectors — no runtime, no JS.
1 · tab system (data-state="active"/"inactive") — Radix pattern
Click a tab. Each tab carries data-state = active
or inactive; the active one is styled cyan via
data-[state=active]:, the rest are slate via
data-[state=inactive]:. This is the exact attribute Radix UI
<Tabs.Trigger> writes — no .is-active class,
no state prop drilling, just an attribute the styling can read.
overview
The active tab reads its cyan background straight off
data-state="active" — toggle the attribute and the style
follows. No class swap, no useState, no conditional className.
details
Each panel also uses data-[hidden]:hidden — a presence
variant. Add the data-hidden attribute and the panel is
display:none; remove it and it shows. One attribute, one rule.
reviews
data-[state=active]:shadow-[0_-2px_0_0_var(--accent)] proves
you can stack any utility behind a data variant — even arbitrary
shadow values. The active tab gets a top accent bar for free.
gold-tab data-state: — · background-color: —
2 · loading card (data-loading="true"/"false") — shadcn pattern
A card that shows a spinner + dimmed content while loading, then flips to a
success badge when done. The whole transition is one attribute:
data-loading="true" vs "false". shadcn/ui's
<Button loading> and async form components use this exact
contract.
Fetching data…
background & border flip with the attribute
3 · dropdown menu (data-open presence) — Headless UI pattern
Headless UI's <Menu> emits a bare data-open
attribute on the trigger when expanded (no value — just presence). The
data-[open]: variant matches the attribute existing,
regardless of value, which is the cleanest "open/closed" signal in HTML.
The wrapper #dd-menu uses both
data-[open]:block (presence → show) and
data-[:not([open])]:hidden (arbitrary selector mode → hide
when absent). Together they cover both halves of the open/closed toggle.
data-* vs aria-* — two attribute channels, pick by intent
Tailwind v4 ships both data-* and aria-* variants.
They look symmetric but mean different things: aria-* attributes
are semantically defined by the WAI-ARIA spec (screen readers consume
them), while data-* is a free-form app channel only your
code reads. Style off aria- when the attribute also carries
accessibility meaning; style off data- for purely visual state.
| aspect | data-* variants | aria-* variants |
|---|---|---|
| spec owner | none — data-* is a generic app extension point |
WAI-ARIA — names + values are standardized |
| who reads it | only your CSS / JS (no user-agent consumes it) | screen readers, AT, browser a11y heuristics |
| common values | data-state, data-open, data-loading — anything you invent |
aria-expanded, aria-checked, aria-disabled, aria-invalid |
| Tailwind v4 syntax | data-[open]: · data-[state=active]: |
aria-expanded: · aria-[checked=true]: · aria-disabled: |
| built-in shorthand? | no — always bracket form data-[…]: |
yes — aria-checked: is shorthand for aria-checked="true" |
| use when… | visual-only state a library already emits (Radix/Headless UI) | state that is ALSO an accessibility fact (expanded, invalid, pressed) |
Rule of thumb: if a screen reader should announce the state, use
aria-* (and get the styling hook for free). If it's internal
UI plumbing — loading flags, layout mode, animation phase — use
data-*. See a11y variants
for the full aria-* / motion-safe: toolkit.
intent → pattern
| intent | pattern | why |
|---|---|---|
| style when attribute is present | data-[open]:block |
presence check — matches [data-open] with any value |
| style on an exact value | data-[state=active]:bg-cyan-600 |
compiles to [data-state="active"] |
| negate / arbitrary selector | data-[:not([open])]:hidden |
drop a raw selector inside [] — escape hatch |
| style a child based on parent's data attr | group/data-[open] + group-data-[open]:… |
combine with named groups for ancestor-driven styling |
| respond to sibling's data attr | peer/data-open + peer-data-[open]:… |
forward-sibling data variant — disclosure arrows, etc. |
| define a reusable custom variant | @custom-variant loading (&[data-loading="true"]) |
then write loading:animate-pulse instead of the bracket form |
| stack with responsive / theme | md:data-[state=active]:bg-cyan-600 |
variants compose left-to-right — responsive wraps the data variant |