Data Attribute Variants and Custom Variants for State-Driven UI in Tailwind
AI generated
</>
tw
Tailwind CSS · State-Driven UI · Alpine.js
Data Attribute Variants and Custom Variants
state-driven UI without JavaScript class toggling

Data attribute variants like data-[state=open] let Tailwind react directly to states that a component library or Alpine.js sets in the markup, with no JavaScript manually adding or removing CSS classes. Combined with custom variants through @custom-variant, this produces state-driven UI that cleanly separates state from presentation.

17 min read data-[state] · @custom-variant · Alpine.js Tailwind v4 · CSS-first config

1. Why state-driven UI needs more than class toggling

The classic approach for state-dependent presentation is adding or removing CSS classes through JavaScript, for example element.classList.toggle('is-open'). That approach mixes two different responsibilities though: a component's state lives in JavaScript, while the presentation of that state lives in CSS class names that have to be kept in sync by hand. Data attribute variants break up that coupling by letting Tailwind react directly to a data-* attribute that a component maintains for its own state anyway.

Many modern component libraries, including Alpine.js plugins and headless UI libraries, already set data-state attributes with values like open, closed or active directly on a component's root element. Data attribute variants tap into exactly that existing information instead of introducing a second, redundant source of state through CSS classes. That significantly reduces the sources of error, because there is only one single truth about the current state.

For state-driven UI that means: the state gets set once in the markup as an attribute, and every visual reaction to it, from simple color changes to complex transitions, gets described declaratively through Tailwind classes using data attribute variants and custom custom variants, with no need for JavaScript to know which CSS classes are responsible for which state.

2. Data attribute variants: syntax and how they work

The basic syntax for data attribute variants is data-[attribute=value]:utility, for example data-[state=open]:block or data-[disabled]:opacity-50 for an attribute without a fixed value. Tailwind generates an attribute selector from that, which matches exactly when the corresponding data-* attribute with the specified value is present on the element, regardless of whether that attribute was set by a component library, by Alpine.js, or by server-rendered markup.

An important difference from classic state variants like hover: or focus:: data attribute variants do not react to browser pseudo-classes, but to actual markup that must be explicitly set and removed again. That makes them ideal for states an application manages itself, such as whether an accordion is open, which tab is active, or whether a form field is currently being validated, states that cannot be derived directly from native browser interactions.


<!-- Data attribute variant reacts to data-state set anywhere in the DOM,
     regardless of whether Alpine.js, a headless library or server-rendered
     markup set the attribute -->
<div data-state="open" class="hidden data-[state=open]:block">
  Panel content
</div>

<!-- Boolean-style attribute without a fixed value -->
<button data-loading class="data-[loading]:opacity-50 data-[loading]:pointer-events-none">
  Save
</button>

3. Combining data attributes with Alpine.js

Alpine.js is especially well suited to setting data-* attributes reactively, without a developer having to maintain state logic twice, once in JavaScript and once in CSS class names. With x-bind:data-state, shorthand :data-state, the attribute can be bound directly to an Alpine variable, so it updates automatically as soon as the underlying state changes. Tailwind's data attribute variants react to that with no additional wiring at all.

This combination is especially valuable in Hyvä themes, where Alpine.js is the only JavaScript layer anyway. Instead of writing :class="open ? 'block' : 'hidden'" inside an Alpine component, which pulls presentation logic into the JavaScript layer, data attribute variants keep the entire visual logic in Tailwind classes in the markup, while Alpine.js is exclusively responsible for the state change itself. This separation makes later design adjustments considerably easier, because no JavaScript code needs to be touched.


<!-- Alpine.js manages state, data attribute variant handles all visual reaction -->
<div x-data="{ open: false }">
  <button x-on:click="open = !open" class="font-semibold text-slate-800">
    Show details
  </button>

  <div
    x-bind:data-state="open ? 'open' : 'closed'"
    class="hidden data-[state=open]:block mt-2 p-4 rounded-lg bg-slate-50">
    Additional product information
  </div>
</div>

4. ARIA variants as a semantically related concept

Besides data-* attributes, Tailwind supports its own predefined variants for ARIA attributes, such as aria-expanded:, aria-selected: or aria-disabled:. The crucial difference from data attribute variants: ARIA attributes carry a fixed, standardized meaning for screen readers and other assistive technologies, while data-* attributes are freely definable and carry no accessibility semantics at all.

A good rule of thumb for state-driven UI: wherever a state must be set as an ARIA attribute for accessibility reasons anyway, for example aria-expanded="true" on an accordion, the visual presentation should be coupled directly to that ARIA attribute instead of additionally maintaining a redundant data-state attribute. Only for states with no direct ARIA equivalent, such as a purely visual loading state, are custom data-* attributes the right choice.

5. Defining your own custom variants with @custom-variant

For recurring state patterns that go beyond the built-in variants, Tailwind v4 allows defining your own custom variants directly in CSS through the @custom-variant directive. That lets a frequently used but cumbersome selector, such as a specific data-* pattern or a combination of several conditions, get encapsulated under a short, descriptive variant name that can then be used anywhere in the project just like a built-in variant.

This encapsulation pays off especially well in design systems with many recurring components. Instead of writing data-[state=active]: over and over in every file, a custom variant like active: can be defined once in the project context, generating the same attribute selector internally while making it immediately obvious in the code that this is a project-specific application state, not a native browser pseudo-class.


/* app.css — defining a reusable custom variant for a recurring data attribute pattern */
@import "tailwindcss";

@custom-variant active (&:is([data-state="active"], [data-state="active"] *));
@custom-variant closing (&[data-state="closing"]);

/* Usage anywhere in the project, behaves like a built-in variant:
   <div data-state="active" class="opacity-50 active:opacity-100"> */

6. Combined variants for multiple simultaneous states

Real components rarely have just a single state. A dropdown can be open and disabled at the same time, a tab can be active and focused simultaneously. Tailwind allows chaining multiple data attribute variants and built-in variants in any combination, for example data-[state=open]:focus:ring-2, which only applies the utility when both conditions are true at once.

With more than two or three combined conditions, though, the class list quickly becomes hard to read. This is where a custom combination variant, defined through @custom-variant, pays off, bundling the frequently co-occurring condition, such as open and interactive at the same time, under a single, short name. That investment is worthwhile especially for components that need the same state combination repeatedly across many variations.

7. Practical example: a tab component with data-state

A tab component is a classic example of state-driven UI: exactly one tab panel is visible at any given time, while the corresponding tab button is visually highlighted. With data-state="active" on the active tab button and its associated panel, the entire presentation logic can be expressed exclusively through data attribute variants, while Alpine.js is only responsible for which data-state attribute on which element currently holds the value active.

The advantage of this pattern shows especially in testing: an automated test can check a component's state purely through the presence and value of the data-state attribute, independent of the actual CSS classes used. That decouples tests from design changes that only touch Tailwind classes, as long as the underlying data-state attribute stays unchanged.


<div x-data="{ activeTab: 'details' }" class="border-b border-slate-200">
  <div class="flex gap-4">
    <button
      x-on:click="activeTab = 'details'"
      x-bind:data-state="activeTab === 'details' ? 'active' : 'inactive'"
      class="pb-2 border-b-2 border-transparent text-slate-500 data-[state=active]:border-sky-600 data-[state=active]:text-sky-700">
      Details
    </button>
    <button
      x-on:click="activeTab = 'reviews'"
      x-bind:data-state="activeTab === 'reviews' ? 'active' : 'inactive'"
      class="pb-2 border-b-2 border-transparent text-slate-500 data-[state=active]:border-sky-600 data-[state=active]:text-sky-700">
      Reviews
    </button>
  </div>

  <div x-show="activeTab === 'details'" class="pt-4">Product details ...</div>
  <div x-show="activeTab === 'reviews'" class="pt-4">Customer reviews ...</div>
</div>

8. Practical example: a toast system with enter and exit states

A notification toast that flies in on appearance and fades out on dismissal is a case where data attribute variants offer clear advantages over plain x-show. With a three-valued data-state attribute, for example entering, visible and leaving, each phase can get its own transition utilities, with no need to write separate Alpine transition directives for every single property.

This pattern scales well to several simultaneously visible toasts, because each toast carries its own data-state independently and Tailwind reacts correctly per element, with no central JavaScript logic needed to keep the visual state of all toasts in sync. The separation of state in the attribute and presentation in Tailwind classes stays clearly traceable even with several instances active at once.


<div
  x-data="{ state: 'entering' }"
  x-init="setTimeout(() => state = 'visible', 20)"
  x-bind:data-state="state"
  class="transition-all duration-300
         data-[state=entering]:opacity-0 data-[state=entering]:translate-y-2
         data-[state=visible]:opacity-100 data-[state=visible]:translate-y-0
         data-[state=leaving]:opacity-0 data-[state=leaving]:translate-y-2
         bg-slate-900 text-white rounded-lg px-4 py-3 shadow-lg">
  Item added to cart
</div>

9. State strategies compared

The following overview shows which strategy fits best for which kind of state.

Strategy Best for Risk if misused
JavaScript classList.toggle Very simple one off cases without repetition State and presentation mix together quickly
Data attribute variants Application specific states without ARIA equivalent No accessibility semantics
ARIA variants States with direct meaning for screen readers Do not repurpose for purely visual states
Custom variants (@custom-variant) Recurring state patterns in design systems Requires central maintenance and documentation

In practice, robust state-driven UI architectures combine all four approaches deliberately: ARIA variants for accessibility relevant states, data attribute variants for purely visual application states, and custom custom variants for the recurring combinations of both.

Mironsoft

Tailwind and Alpine.js state-driven UI components

State logic and presentation cleanly separated?

We build tab, toast and accordion components with data attribute variants and custom variants, testable through plain attribute state and with no manual class toggling.

Component audit

Analyzing existing class toggling patterns in Alpine.js code

Custom variants

Encapsulating recurring state patterns as a custom @custom-variant

Accessibility

Correctly separating ARIA variants from purely visual states

10. Summary

Data attribute variants like data-[state=open] solve a fundamental architecture problem in state-driven UI: they let Tailwind react directly to states a component already maintains in the markup, instead of introducing a second, redundant source of state through manually toggled CSS classes. Combined with Alpine.js in Hyvä themes, a clear separation emerges: Alpine.js manages state exclusively, Tailwind describes exclusively the visual reaction to it.

Custom custom variants defined through @custom-variant extend this pattern for recurring state combinations in design systems, while ARIA variants are used wherever a state is required for accessibility anyway. Anyone who applies these three tools deliberately according to their respective purpose, instead of mixing them, builds components that stay both easy to test and easy to maintain.

Data Attribute Variants and Custom Variants — Key Takeaways

Syntax

data-[attribute=value]:utility reacts directly to markup attributes, regardless of their origin.

Alpine.js coupling

x-bind:data-state binds the attribute to an Alpine variable, with no manual class toggling at all.

Custom variants

@custom-variant encapsulates recurring state patterns under a short, descriptive name.

ARIA vs. data-*

ARIA for accessibility relevant states, data-* for purely visual application states.

11. FAQ: Data Attribute Variants and Custom Variants

1What is a data attribute variant?
Applies a utility only when a specific data attribute with the given value is present.
2Combination with Alpine.js?
x-bind:data-state binds the attribute to an Alpine variable, Tailwind reacts automatically.
3Difference from ARIA variants?
ARIA has fixed meaning for screen readers, data attributes are freely definable without accessibility semantics.
4How to define a custom variant?
With @custom-variant in Tailwind v4, usable afterward like a built-in variant across the project.
5When is a custom variant worth it?
As soon as the same attribute selector repeats in the project, encapsulation improves readability noticeably.
6Combinable variants?
Yes, chained arbitrarily, for example data-[state=open]:focus:ring-2, applies only when all conditions are met.
7Why more testable?
Tests check only the data attribute, independent of actual CSS classes, design changes don't break tests.
8Suited for multi step transitions?
Yes, with entering, visible, leaving as values, each phase gets its own transition utilities.
9Do they replace x-show entirely?
No, x-show controls DOM visibility, data attribute variants the fine grained visual reaction to it.
10Do they scale to multiple instances?
Yes, each instance carries its own data-state attribute, no central synchronization needed.