States & Variants
Style hover, focus, disabled, validation and structural states directly in your markup — plus group-*, peer-*, has-* and not-* for styling across elements.
A variant is a prefix that scopes a utility to a condition. hover:bg-blue-600 compiles to a :hover rule; md:flex compiles to a media query. Everything Tailwind can do beyond a flat style is a variant.
Overview#
<div class="p-4">
<button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 active:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50">
Save changes
</button>
<button disabled class="ml-2 rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50">
Disabled
</button>
</div>Variants stack, and they read left to right: dark:md:hover:bg-gray-700 means on hover, at the medium breakpoint, in dark mode.
The states you will use most#
| Variant | Applies when | Typical use |
|---|---|---|
hover: | Pointer is over the element | Colour and elevation changes |
focus: | Element has focus | Inputs |
focus-visible: | Focus should be shown | Buttons and links |
focus-within: | A descendant has focus | Highlighting a field wrapper |
active: | Being pressed | Tactile feedback |
disabled: | disabled attribute present | Dimming, cursor |
checked: | Checkbox/radio is checked | Toggles |
visited: | Link already visited | Long content |
group-* and peer-*#
These are the two highest-leverage variants in Tailwind: they let a child react to a different element's state.
group-* — react to an ancestor#
Mark the parent group, then use group-hover:, group-focus:, group-has-* and friends on descendants.
<a href="#" class="group mx-auto flex max-w-sm items-center gap-4 rounded-xl border border-gray-200 bg-white p-4 transition hover:border-indigo-400 hover:bg-indigo-50">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-gray-100 text-gray-500 transition group-hover:bg-indigo-600 group-hover:text-white">
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14m-6-6 6 6-6 6"/></svg>
</span>
<span>
<span class="block font-medium text-gray-900 transition group-hover:text-indigo-700">Continue setup</span>
<span class="block text-sm text-gray-500">Three steps remaining</span>
</span>
</a>Name groups when they nest:
<div class="group/row">
<div class="group/cell">
<!-- Reacts to the row, not the cell -->
<button class="opacity-0 group-hover/row:opacity-100">Edit row</button>
<!-- Reacts to the cell only -->
<span class="hidden group-hover/cell:inline">Cell hint</span>
</div>
</div>Without a name, group-hover: matches the nearest group ancestor, which is rarely what you want once groups nest.
peer-* — react to a previous sibling#
<div class="mx-auto max-w-sm p-4">
<label class="block">
<span class="mb-1 block text-sm font-medium text-gray-700">Email</span>
<input type="email" required placeholder="you@example.com"
class="peer w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none user-invalid:border-red-500" />
<span class="mt-1 hidden text-sm text-red-600 peer-user-invalid:block">
Enter a valid email address.
</span>
</label>
</div>has-* — style a parent from its children#
<label class="rounded-lg border p-4 has-checked:border-indigo-500 has-checked:bg-indigo-50">
<input type="radio" name="plan" class="accent-indigo-600" />
Pro plan
</label>Combine with group:
<div class="group">
<div class="group-has-[a]:mt-4">…</div>
</div>not-* — invert a condition#
v4
<!-- Underline links except when hovered -->
<a class="underline not-hover:decoration-dotted" href="#">…</a>
<!-- Fallback for browsers without a feature -->
<div class="not-supports-[backdrop-filter]:bg-white">…</div>Structural and child variants#
| Variant | Selector |
|---|---|
first: / last: / only: | :first-child / :last-child / :only-child |
odd: / even: | :nth-child(odd) / :nth-child(even) |
nth-3: / nth-last-2: | :nth-child(3) / :nth-last-child(2) |
empty: | :empty |
*: | Direct children — :is(& > *) |
**: | All descendants — :is(& *) |
<ul class="*:border-b *:py-2 last:*:border-b-0">
<li>First</li>
<li>Second</li>
</ul>Form validation states#
| Variant | Applies when |
|---|---|
required: / optional: | Field is required or not |
valid: / invalid: | Value passes/fails constraints |
user-valid: / user-invalid: | Same, but only after the user has interacted v4.1 |
in-range: / out-of-range: | Numeric input within min/max |
placeholder-shown: | Input is empty and showing its placeholder |
autofill: | Browser autofilled the field |
read-only: | readonly attribute present |
Pseudo-elements#
| Variant | Element |
|---|---|
before: / after: | ::before / ::after — need content-* |
placeholder: | Input placeholder text |
selection: | Highlighted text |
marker: | List bullets and numbers |
file: | File input's button |
first-letter: / first-line: | Typographic flourishes |
backdrop: | <dialog> backdrop |
<input type="file" class="file:mr-4 file:rounded-full file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100" />Media and preference variants#
| Variant | Condition |
|---|---|
dark: | prefers-color-scheme: dark — see Dark Mode |
motion-reduce: / motion-safe: | prefers-reduced-motion |
contrast-more: / contrast-less: | prefers-contrast |
forced-colors: | Windows High Contrast mode |
pointer-coarse: / pointer-fine: | Touch vs mouse v4.1 |
noscript: | JavaScript disabled v4.1 |
print: | Printing |
supports-[…]: | @supports feature query |
<!-- Bigger tap targets on touch devices -->
<button class="p-2 pointer-coarse:p-3">…</button>Attribute variants#
<!-- ARIA -->
<div aria-expanded="true" class="aria-expanded:rotate-180">…</div>
<!-- Data attributes -->
<div data-state="open" class="data-[state=open]:block hidden">…</div>
<!-- Text direction -->
<span class="ltr:ml-2 rtl:mr-2">…</span>Advanced example: an accessible dropdown#
<div class="p-4">
<details class="group relative mx-auto w-56">
<summary class="flex cursor-pointer items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-900 marker:content-[''] hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Choose a plan
<svg class="size-4 text-gray-500 transition group-open:rotate-180" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="m6 9 6 6 6-6"/></svg>
</summary>
<ul class="mt-1 overflow-hidden rounded-md border border-gray-200 bg-white text-sm shadow-lg">
<li><a href="#" class="block px-3 py-2 hover:bg-indigo-50 hover:text-indigo-700">Starter</a></li>
<li><a href="#" class="block px-3 py-2 hover:bg-indigo-50 hover:text-indigo-700">Pro</a></li>
<li><a href="#" class="block px-3 py-2 hover:bg-indigo-50 hover:text-indigo-700">Enterprise</a></li>
</ul>
</details>
</div>group-open:rotate-180 reads the <details> element's own open state — no JavaScript.
Common mistakes#
- Forgetting
transition.hover:bg-indigo-500snaps unless the base state hastransition. peerafter the target. Sibling selectors only look forward.- Missing
content-['']onbefore:/after:. Without content the pseudo-element does not render. Tailwind setscontent: ""by default for these variants, but an explicitcontent-['']is clearer when you also set other properties. groupon the wrong element. It must be on an ancestor of everything usinggroup-*.- Overusing
invalid:. Useuser-invalid:.
Related topics#
- Variant Reference — the complete table
- Custom Variants — define your own with
@custom-variant - Forms — validation states in context
- Dark Mode — the
dark:variant in depth