data attribute variants

[check: …]
📖 Pair this live Tailwind demo with the companion guide (.md) — this page is the rendered ground truth. ↗ Builds on group & peer variants — those read state from a marker class (group/peer); data-* variants read state from a real HTML attribute, which is what headless UI libraries already emit.

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.

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

aspectdata-* variantsaria-* 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

intentpatternwhy
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