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.
Table of contents
- 1. Why state-driven UI needs more than class toggling
- 2. Data attribute variants: syntax and how they work
- 3. Combining data attributes with Alpine.js
- 4. ARIA variants as a semantically related concept
- 5. Defining your own custom variants with @custom-variant
- 6. Combined variants for multiple simultaneous states
- 7. Practical example: a tab component with data-state
- 8. Practical example: a toast system with enter and exit states
- 9. State strategies compared
- 10. Summary
- 11. FAQ
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.