Style State with Tailwind v4 data-* and aria-* Variants

Tailwind v4 data attribute variants style component state from data-* and aria-* attributes, so Alpine flips a single attribute instead of a class string.

Steven Richardson
Steven Richardson
· 9 min read

A disclosure panel starts as one line of Alpine and ends up as a :class expression nobody wants to touch. Open, closed, loading, selected — each state is another ternary spliced into a string, and half of them have quietly drifted out of step with the aria-expanded sitting two attributes to the left. Tailwind v4 data attribute variants fix that: flip one attribute, and let the variant do the styling.

What Tailwind v4 data attribute variants compile to#

Here is the version I keep inheriting. The class list is a runtime string, the panel's real state lives in a boolean, and nothing on the element tells a screen reader anything.

<div x-data="{ open: false }">
    <button @click="open = ! open">Shipping and returns</button>

    <div :class="open ? 'block border-t border-zinc-200 p-4' : 'hidden'">
        <!-- ... -->
    </div>
</div>

Now the attribute version. Alpine sets one data-state attribute; every class is a literal in the template.

<div x-data="{ open: false }">
    <button @click="open = ! open">Shipping and returns</button>

    <div
        :data-state="open ? 'open' : 'closed'"
        class="border-t border-zinc-200 p-4 data-[state=closed]:hidden data-[state=open]:block"
    >
        <!-- ... -->
    </div>
</div>

data-[state=open]:block is not doing anything clever. It compiles to a plain CSS attribute selector:

.data-\[state\=open\]\:block[data-state="open"] {
  display: block;
}

The bracket form matches an exact value. Drop the brackets and you get a bare presence check instead — the attribute just has to exist, whatever its value:

<!-- Matches: [data-loading] -->
<div data-loading class="opacity-100 data-loading:opacity-50">

Both forms have the same payoff. Every class name appears verbatim in the file, so Tailwind's scanner finds them at build time. That is the whole reason this pattern beats string concatenation — no more reaching for @source inline() to safelist classes Blade builds at runtime, because there is nothing dynamic left to safelist.

If you only care that the attribute exists but want to keep the bracket syntax, data-[state]:block compiles to [data-state]. And for space-separated token lists, use the ~= operator: data-[ui~=checked]:underline compiles to [data-ui~="checked"].

Let aria-* attributes carry the state#

data-* is fine for private component state. But the moment a state has an accessibility meaning — expanded, selected, busy, sorted — style the ARIA attribute directly. Then there is exactly one source of truth, and the styling physically cannot drift from the contract you ship to assistive technology.

Tailwind has built-in variants for the boolean ARIA attributes, each compiling to [aria-*="true"]:

Variant Compiles to
aria-busy &[aria-busy="true"]
aria-checked &[aria-checked="true"]
aria-disabled &[aria-disabled="true"]
aria-expanded &[aria-expanded="true"]
aria-hidden &[aria-hidden="true"]
aria-pressed &[aria-pressed="true"]
aria-readonly &[aria-readonly="true"]
aria-required &[aria-required="true"]
aria-selected &[aria-selected="true"]

So the disclosure button styles itself off the attribute a screen reader is already reading:

<button
    :aria-expanded="open"
    aria-controls="shipping-panel"
    @click="open = ! open"
    class="w-full rounded-md px-4 py-3 text-left aria-expanded:bg-zinc-100 aria-expanded:font-medium"
>
    Shipping and returns
</button>

ARIA attributes that are not booleans need the arbitrary form, where you supply the value yourself. A sortable table header is the usual case:

<th
    aria-sort="ascending"
    class="cursor-pointer aria-[sort=ascending]:after:content-['▲'] aria-[sort=descending]:after:content-['▼']"
>
    Invoice #
</th>

That compiles to [aria-sort="ascending"]::after and [aria-sort="descending"]::after. No JavaScript decides which arrow to render — the attribute already knows.

Push state to children with group-data-* and peer-aria-*#

State usually lives on a parent or a sibling, not on the element you want to restyle. Both data-* and aria-* compose with group-* and peer-*.

Mark the parent with group and the chevron rotates itself:

<button
    :aria-expanded="open"
    aria-controls="shipping-panel"
    @click="open = ! open"
    class="group flex w-full items-center justify-between px-4 py-3"
>
    <span>Shipping and returns</span>

    <svg class="size-4 transition-transform duration-200 group-aria-expanded:rotate-180">
        <!-- ... -->
    </svg>
</button>

group-aria-expanded:rotate-180 compiles to :is(:where(.group)[aria-expanded="true"] *). The same shape works for data attributes — group-data-[state=open]:rotate-180 gives you :is(:where(.group)[data-state="open"] *).

The peer-* family works on siblings, with one constraint worth remembering: it compiles to a general sibling combinator (~), so the peer must come before the element you are styling in the DOM.

<input id="email" type="email" :aria-invalid="! valid" class="peer rounded-md border px-3 py-2">

<p class="mt-1 hidden text-sm text-red-600 peer-aria-invalid:block">
    Enter a valid email address.
</p>

If the state is on an arbitrary descendant rather than a marked group, has-data-* looks down and in-data-* looks up. Livewire 4 makes this concrete: it adds a data-loading attribute automatically to any element that triggers a network request, so the loading UI is pure CSS.

<button wire:click="save" class="data-loading:cursor-wait data-loading:opacity-50">
    <span class="in-data-loading:hidden">Save changes</span>
    <span class="hidden in-data-loading:block">Saving…</span>
</button>

No wire:target, no class juggling. If you need to react to state anywhere in a subtree rather than on a marked parent, that is the same territory as styling a parent from its children with the :has() variant.

Name a repeated data attribute variant with @custom-variant#

Once data-[state=open]: appears in six components, the arbitrary syntax stops paying rent. Name it once in CSS with @custom-variant:

/* resources/css/app.css */
@import "tailwindcss";

@custom-variant panel-open (&[data-state="open"]);
@custom-variant aria-asc (&[aria-sort="ascending"]);
@custom-variant aria-desc (&[aria-sort="descending"]);

Then the markup reads like intent rather than a selector:

<div :data-state="open ? 'open' : 'closed'" class="hidden panel-open:block">
    <!-- ... -->
</div>

Named variants compose exactly like built-ins — group-panel-open:rotate-180, peer-panel-open:block and not-panel-open:hidden all compile. That last one pairs nicely with the not-* variant for styling the exception.

@custom-variant is the same directive behind CSS-first dark mode in Tailwind v4, and it is the variant-side counterpart to @utility for custom utilities. Variants describe when a utility applies; utilities describe what it does.

Wiring the attribute up in Alpine and Livewire#

Alpine's x-bind sets any attribute, so the same shorthand you use for :class sets :data-state and :aria-expanded:

<div
    x-data="{ open: false, busy: false }"
    :data-state="open ? 'open' : 'closed'"
    :data-busy="busy"
    class="data-busy:animate-pulse data-[state=open]:shadow-lg"
>
    <!-- ... -->
</div>

:data-busy="busy" is the bare-presence pattern: when busy is false Alpine removes the attribute entirely, so data-busy:animate-pulse stops matching. That is the behaviour you want — but it is also the source of the sharpest edge in this whole approach, which is next.

On the Livewire side you rarely need to bind anything at all. Livewire 4 ships data-loading for free, and wire:loading.attr="disabled" lets you toggle any other attribute during a request. Pair that with aria-disabled: or aria-busy: and the loading state, the styling and the accessibility announcement are the same fact expressed once. If you are already entangling state across the Alpine/Livewire boundary, the same reasoning applies to wire:entangle and modal state.

Gotchas and Edge Cases#

Alpine removes falsy attributes — except four ARIA ones. Alpine's bind helper drops any attribute bound to null, undefined or false, with a hardcoded exception list: aria-pressed, aria-checked, aria-expanded and aria-selected are preserved as ="false". So :aria-expanded="open" keeps rendering aria-expanded="false" when closed, but :data-state="open && 'open'" deletes data-state entirely — and your data-[state=closed]:hidden never fires. Return the closed string explicitly: :data-state="open ? 'open' : 'closed'".

Bare aria-* variants always mean "true". This bites on non-boolean attributes. aria-current:font-semibold compiles to [aria-current="true"], which will never match the aria-current="page" you actually wrote on the nav link. Use aria-[current=page]:font-semibold.

A custom variant can shadow the bare-attribute form. Declaring @custom-variant data-open (&[data-state="open"]) means data-open:block now compiles to [data-state="open"], not [data-open]. That is documented behaviour for the data-* namespace, and it is useful — but if a teammate later writes <div data-open> expecting presence matching, nothing applies. Name custom variants outside the data-* namespace unless you mean to override it.

Attribute values are case-sensitive. data-state="Open" will not match data-[state=open]. If the value comes from a PHP enum or a model column, normalise it before it reaches the template.

Class names still have to be literal. Attributes solve the safelist problem for state, not for class generation. data-[state={{ $status }}]:bg-red-500 is still a dynamic class name and Tailwind will not see it. Keep the variable in the attribute value, never in the class.

Wrapping Up#

Pick one attribute per state, bind it, and style it. Use aria-* whenever the state has an accessible meaning and data-* for everything else. When the same arbitrary selector shows up a third time, promote it to a @custom-variant.

The natural next step is animating those states properly — @starting-style and discrete transitions handle the enter animation that hidden/block toggling can't. And if you are styling form state, the user-valid and user-invalid variants get you most of the way there without binding aria-invalid yourself.

FAQ#

How do I style based on a data attribute in Tailwind?

Prefix any utility with the data-* variant. data-[size=large]:p-8 matches an exact attribute value and compiles to [data-size="large"], while the bracket-free form data-active:border-purple-500 matches whenever the data-active attribute is present, regardless of its value. Both are plain CSS attribute selectors under the hood, so there is no runtime cost and no JavaScript involved beyond setting the attribute.

What is the aria-* variant in Tailwind CSS?

The aria-* variant applies a utility conditionally based on an ARIA attribute. Tailwind ships built-in variants for the boolean ARIA attributes — aria-busy, aria-checked, aria-disabled, aria-expanded, aria-hidden, aria-pressed, aria-readonly, aria-required and aria-selected — and each compiles to a selector matching the value "true". For any other ARIA attribute or value, use the arbitrary form such as aria-[sort=ascending]:.

How do I style data-state=open in Tailwind v4?

Write data-[state=open]:block and Tailwind generates a rule scoped to [data-state="open"]. Set the attribute from your framework — in Alpine that is :data-state="open ? 'open' : 'closed'" — and pair it with data-[state=closed]:hidden for the inverse. If you use the pattern repeatedly, register @custom-variant panel-open (&[data-state="open"]); in your CSS and write panel-open:block instead.

How do I use group-data in Tailwind?

Add the group class to the element that carries the data attribute, then use group-data-* on any descendant you want to restyle. For example group-data-[state=open]:rotate-180 on a chevron inside a group parent rotates it whenever the parent has data-state="open". It compiles to :is(:where(.group)[data-state="open"] *), and the sibling equivalent is peer-data-*, which requires the peer element to appear before the styled element in the DOM.

How do I create a custom variant in Tailwind v4?

Use the @custom-variant directive in a CSS file that Tailwind processes, after the @import "tailwindcss" line. The syntax is @custom-variant name (selector); — for example @custom-variant panel-open (&[data-state="open"]); — where & is the element the utility is applied to. This replaces the addVariant() JavaScript plugin from v3, and the resulting variant composes with group-*, peer-* and not-* exactly like a built-in one.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.