06 · Advanced Variants: aria, data, has, not & Custom Variants¶
Interactive components carry their state somewhere. A disclosure button has
aria-expanded="true", a tab has data-state="active", a form row contains an input that
has focus. A common anti-pattern is to mirror that state into extra classes from
JavaScript (is-open, is-active) just so CSS can see it. Tailwind's attribute and
relational variants let you style from the state that's already there. That means less
JavaScript and, with ARIA, styles that can't drift out of sync with what assistive
technology is told.
aria-*: style from accessibility state¶
Boolean ARIA attributes have built-in variants that match the value "true". Note that
aria-expanded: checks the element the class is on. In a disclosure button, the
attribute is on the <button> but the thing you want to rotate is the icon inside it,
so make the button a group and use group-aria-expanded: on the icon:
<button aria-expanded="false" aria-controls="faq-1"
class="group flex w-full items-center justify-between py-3 font-medium">
What is your refund policy?
<svg class="size-5 transition-transform group-aria-expanded:rotate-180"
aria-hidden="true" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.3 7.3a1 1 0 0 1 1.4 0L10 10.6l3.3-3.3a1 1 0 1 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 0-1.4z"/>
</svg>
</button>
<div id="faq-1" hidden class="pb-4 text-gray-600">Full refund within 30 days.</div>
The JavaScript only toggles the attribute (which it must do anyway for screen readers), and the styling follows:
for (const btn of document.querySelectorAll("[aria-controls]")) {
btn.addEventListener("click", () => {
const open = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!open));
document.getElementById(btn.getAttribute("aria-controls")).hidden = open;
});
}
aria-{name}: works for any attribute name and always tests for the value "true".
We compiled aria-expanded:, aria-busy:, aria-selected: and even a made-up
aria-foo:, and each became [aria-…="true"]. That's right for true/false attributes
(aria-busy, aria-checked, aria-disabled, aria-expanded, aria-pressed,
aria-selected and so on), but wrong for attributes with other values. The classic
example is navigation: aria-current is normally "page", so aria-current:font-bold
never matches. Use brackets for those: aria-[current=page]:font-bold, or
aria-[sort=ascending]:bg-sky-50, which compiled to [aria-sort="ascending"].
data-*: style from component state¶
Headless UI libraries (Radix, Headless UI, Reach, Ark and others) expose state as data attributes, and they're handy in your own components too:
<button data-state="active" class="border-b-2 border-transparent px-3 py-2
data-[state=active]:border-sky-600 data-[state=active]:text-sky-700">
Overview
</button>
Compiled: .data-\[state\=active\]\:border-sky-600[data-state="active"].
Without brackets, data-active: checks that the attribute exists, whatever its value.
That has a sharp edge: we rendered <div data-active="false" class="data-active:font-bold">
and it computed to font-weight: 700. The attribute exists, so the style applies. Either
remove the attribute when it's off, or match a value explicitly with
data-[active=true]:. ARIA variants don't have this problem: aria-expanded: requires
"true", and our aria-expanded="false" button stayed at weight 400.
has-*: style a parent from what's inside it¶
:has() is CSS's parent selector, and Tailwind exposes it as has-*:
<label class="flex items-center gap-3 rounded-lg border border-gray-200 p-4
has-checked:border-sky-500 has-checked:bg-sky-50
has-focus-visible:ring-2 has-focus-visible:ring-sky-500">
<input type="radio" name="plan" value="pro" class="size-4 accent-sky-600">
<span class="font-medium">Pro</span>
</label>
The whole card highlights when its radio is checked, and shows a ring when the radio has
keyboard focus. That used to need JavaScript. Any state variant works after has-, and
brackets take any selector: has-[input:focus]:ring-2 compiled to
:has(:is(input:focus)), and in our browser test the wrapper got its 2px ring as soon as
the input was focused.
group-has-* and peer-has-* combine the two ideas: group-has-[img]:p-0 removes a
card's inner padding only when the card contains an image.
:has() is supported in all current major browsers, but it's newer than most CSS here.
If you must support older browsers, make sure the page still works (just less
decorated) without it.
not-*: the opposite of any variant¶
not- negates a pseudo-class, a selector, or even a media query:
<li class="py-3 not-last:border-b not-last:border-gray-200">…</li>
<a class="text-gray-500 not-[.active]:hover:text-gray-900">…</a>
not-last:border-b is a tidy alternative to border-b last:border-0. Negating a media
variant flips the query: not-hover:opacity-80 compiled to both :not(:hover) and an
@media not (hover: hover) block, so it also applies on touch devices.
in-*: like group, without the marker¶
in-* checks a state on any ancestor, without needing a group class:
<div data-state="open">
<span class="invisible in-data-[state=open]:visible">Visible when open</span>
</div>
Compiled: :where([data-state="open"]) .in-data-\[state\=open\]\:visible. It's
convenient when you don't control the ancestor's classes (for example, a library sets
data-state on a wrapper). Because it matches any ancestor, it has the same crosstalk
risk as unnamed groups.
nth-* for positional styling¶
Beyond first:, last:, odd: and even::
| Class | Selector |
|---|---|
nth-3:font-bold |
:nth-child(3) |
nth-[3n+1]:clear-left |
:nth-child(3n+1) |
nth-last-2:mb-0 |
:nth-last-child(2) |
@custom-variant: name your own¶
When you write the same arbitrary variant often, or need a condition Tailwind doesn't ship, define a variant in CSS. The short form takes a selector or at-rule:
@custom-variant theme-midnight (&:where([data-theme=midnight] *));
@custom-variant pointer-coarse (@media (pointer: coarse));
The block form uses @slot to mark where the utility's declarations go, which lets you
combine an at-rule and a selector:
Compiled usage: theme-midnight:bg-indigo-950 became
.theme-midnight\:bg-indigo-950:where([data-theme=midnight] *), and any-hover:underline
became @media (any-hover: hover) { .any-hover\:underline:hover { … } }. Custom variants
stack with all the others (md:theme-midnight:…).
(Tailwind already ships pointer-coarse: and pointer-fine:. Check the built-in list
before defining a variant; the example above just shows the syntax.)
Worked example: a filterable list with no state classes¶
<fieldset class="flex flex-wrap gap-2">
<legend class="sr-only">Filter</legend>
<label class="cursor-pointer rounded-full border border-gray-300 px-3 py-1 text-sm
has-checked:border-sky-700 has-checked:bg-sky-700 has-checked:text-white
has-focus-visible:outline-2 has-focus-visible:outline-offset-2
has-focus-visible:outline-sky-600">
<input type="radio" name="status" value="all" class="sr-only" checked> All
</label>
<label class="cursor-pointer rounded-full border border-gray-300 px-3 py-1 text-sm
has-checked:border-sky-700 has-checked:bg-sky-700 has-checked:text-white
has-focus-visible:outline-2 has-focus-visible:outline-offset-2
has-focus-visible:outline-sky-600">
<input type="radio" name="status" value="open" class="sr-only"> Open
</label>
</fieldset>
<ul class="mt-4 divide-y divide-gray-200" aria-busy="false"
class="aria-busy:opacity-50 aria-busy:cursor-wait">
<li data-status="open" class="flex justify-between py-3 data-[status=closed]:text-gray-400">
Fix login redirect <span class="text-xs">open</span>
</li>
<li data-status="closed" class="flex justify-between py-3 data-[status=closed]:text-gray-400
data-[status=closed]:line-through">
Update footer links <span class="text-xs">closed</span>
</li>
</ul>
There's a deliberate bug in that markup. Look at the <ul>: it has two class
attributes. Browsers keep the first and ignore the second, so aria-busy: would never
be applied. It's an easy slip when adding
classes in a hurry, and Tailwind can't warn you (it scans both, generates both, and the
browser drops one). The fix is to merge them:
<ul class="mt-4 divide-y divide-gray-200 aria-busy:cursor-wait aria-busy:opacity-50"
aria-busy="false">
With that fixed:
- The filter chips are real radio buttons (keyboard and screen reader support for free),
visually hidden with
sr-only, styled throughhas-checked:andhas-focus-visible:on their labels. - Each row's appearance comes from its
data-status. - While your script fetches new results it sets
aria-busy="true"on the list, which both tells assistive technology to wait and dims the list.
How It Actually Works¶
These variants are generated from patterns rather than a fixed list. aria-{name}
appends [aria-{name}="true"]; aria-[…] and data-[…] take the bracket contents as an
attribute selector, adding quotes around the value; data-{name} appends a presence
check [data-{name}]. has-{variant} takes another variant's selector and wraps it in
:has(…), not-{variant} wraps it in :not(…) (or negates the media query), and
in-{variant} prepends :where(<selector>) as an ancestor. That compositional design
is why group-has-, peer-aria-, not-has- and so on all work without being defined
individually.
@custom-variant registers a new entry in the same variant registry. With the short
form, & is replaced by the utility's selector; with the block form, the utility's
declarations are inserted at @slot.
Common mistakes¶
aria-current:for the current nav link. Its value is usually"page", not"true"; usearia-[current=page]:.- Using
aria-expanded:on a child of the element that has the attribute. Usegroup-aria-expanded:(orin-aria-expanded:). data-active:withdata-active="false". Presence matches; use a value (data-[active=true]:) or remove the attribute.- Mirroring state into classes (
is-open) when an ARIA or data attribute already carries it. - Duplicate
classattributes on one element. The browser silently ignores the second. in-*or unnamedgroup-*under nested stateful ancestors, which react to the wrong ancestor.- Redefining a variant Tailwind already has. Check the built-ins first.
Exercise¶
- Build an accordion of three questions with
aria-expandedandgroup-aria-expanded:. Confirm with a screen reader or the Accessibility pane that the state is announced. - Recreate the
data-active="false"pitfall, then fix it two ways. - Build radio-button "plan cards" styled only with
has-checked:andhas-focus-visible:. - Define
@custom-variant theme-midnightand build a small card that changes colours when an ancestor hasdata-theme="midnight".