State Variants

Tailwind CSS Fundamentals

Chapter 6 ยท State Variants: Hover, Focus and Friends

So far every class has applied all the time. A variant is a prefix that makes a class apply only in certain circumstances: hover:bg-blue-700 is "the blue-700 background, but only while the pointer is over it". Variants are how Tailwind does everything that plain CSS does with :hover, :focus, :checked, :nth-child(), ::before and attribute selectors, without leaving your HTML. This chapter covers the state-based ones. Responsive and dark-mode variants, which work the same way, are chapters 7 and 8.

Every example here was run
CSS is copied from Tailwind CSS 4.3.3's output. For this chapter I drove headless Chrome with real mouse and keyboard events (through the Chrome DevTools Protocol), so "hover", "pressed", "Tab" and "click" below are real input, and the colours and opacities were read back from the browser. Colours are shown as shade names; I checked the underlying values.

How a Variant Works

A variant puts a colon between a condition and a utility. The utility is unchanged; Tailwind wraps the same declaration in a selector for the condition:

<button class="bg-blue-500 hover:bg-blue-700 active:bg-blue-900 px-4 py-2 text-white">Button</button>
@media (hover: hover) { .hover\:bg-blue-700:hover { background-color: var(--color-blue-700); } } .active\:bg-blue-900:active { background-color: var(--color-blue-900); } measured with real input: at rest blue-500 pointer over blue-700 mouse down blue-900

The class name itself is escaped in the CSS (hover\:bg-blue-700), and the rule only matches in the state. Note the @media (hover: hover) around the hover rule: in v4 hover: applies only on devices whose main input can really hover. On a touch screen it is skipped, so a tap does not leave a button stuck in its hover style. (The headless Chrome I tested with reported matchMedia('(hover: hover)').matches as true.) The upgrade guide says that if you built a site that depended on hover firing on tap, you can restore the old behaviour with @custom-variant hover (&:hover);, but it recommends treating hover as an enhancement and never relying on it.

The Interaction and Form States

Each of these is a pseudo-class with the same name, taken from the generated CSS:

VariantSelector it generatesWhen it applies
hover::hover inside @media (hover: hover)pointer over the element, on devices that can hover
active::activewhile pressed
focus::focusthe element has focus, however it got it
focus-visible::focus-visiblefocus the browser decides should be shown (below)
focus-within::focus-withinthe element or anything inside it has focus
disabled: enabled::disabled, :enabledform controls
checked::checkedcheckboxes, radios, options
required: invalid: read-only::required, :invalid, :read-onlyform validation and editability
placeholder-shown::placeholder-shownan input still showing its placeholder
visited::visitedlinks already followed
open::is([open], :popover-open, :open)a <details>, popover or dialog that is open
empty::emptyno children at all

I tested several with real state. disabled:bg-gray-400 on a button with the disabled attribute gave the grey, and the enabled twin stayed blue. An email input holding nope with invalid:border-red-500 had a red border, and after I changed its value to a@b.co the border went back to grey. A <details open:bg-blue-100> was grey until I clicked its summary, and then blue (open became true).

Focus: focus, focus-visible and focus-within

Focus is the one to get right, because it is how keyboard users see where they are. The two variants differ, and I measured the difference. A button with focus:text-yellow-300 focus-visible:text-red-500, and a text input with the same classes:

Action:focus:focus-visibleWhich class applied
Click the button with the mousetruefalsefocus: (yellow)
Press Tab to reach the button (fresh page)truetrueboth; focus-visible: (red) came last, so it wins
Click into the text inputtruetrueboth (red)

A mouse click gives a button focus but not :focus-visible, because a person who just clicked it does not need an indicator; keyboard users do. So the pattern for buttons and links is to style focus-visible:: a ring or outline for keyboard users, nothing extra after a click. Text inputs always match :focus-visible (you are about to type), so either variant works on them. I measured a button with focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600: after a mouse click its outline style was none; after Tab it was 2px solid with a 2px offset in blue. Never remove the outline without such a replacement (chapter 3).

focus-within: styles a container when something inside it has focus: a wrapper with border-gray-300 focus-within:border-blue-500 around an input was grey, and turned blue as soon as I clicked into the input. It is how you give a fancy input (an icon, a prefix) a focus state on the outer box.

hover: still fires on a disabled button
I put hover:bg-blue-700 and disabled:opacity-50 on a button with the disabled attribute and hovered it: the background still changed to blue-700 (while dimmed). A disabled button is still under the pointer. The fix is the enabled: variant: enabled:hover:bg-blue-700 compiles to :enabled:hover; the disabled button stayed blue-600 when hovered, and an enabled button with the same classes still changed. Use it (and enabled:active:) on anything that can be disabled.

Group and Peer: Styling One Thing from Another's State

A plain variant looks at the element's own state. Often you want to style one element based on the state of another, and Tailwind has two markers for that.

group marks a parent; group-hover:, group-focus: and so on style its descendants:

<div class="group/card border p-2"> <span class="opacity-20 group-hover/card:opacity-100">revealed by the card</span> <div class="group/row bg-gray-100 p-2"> <span class="opacity-20 group-hover/row:opacity-100">revealed by the row</span> <span class="opacity-20 group-hover/card:opacity-100">also by the card</span> </div> </div>
.group-hover\/card\:opacity-100:is(:where(.group\/card):hover *) { opacity: 100%; } (inside @media (hover: hover)) opacities of the three spans: at rest 0.2 0.2 0.2 pointer over the card only 1 0.2 1 pointer over the row 1 1 1

The selector reads "this element, when it is inside a .group that is hovered". When groups nest, a bare group is ambiguous (the nearest one? all of them?), so you name them, group/card and group/row, and write group-hover/card:. In the measurement the card-hover span lit up for the whole card, while the row-hover span lit up only when the pointer was inside the row (and, because the row is inside the card, hovering the row also counted as hovering the card).

peer marks an element so that its later siblings can style themselves from its state: peer-checked:, peer-invalid:, peer-focus-visible:. The generated selector uses the sibling combinator: .peer-checked\:bg-red-500:is(:where(.peer):checked ~ *). The ~ is the catch: the peer must come before the element that reacts to it. I built both orders and clicked both labels:

<!-- works: the peer comes first --> <input id="a" type="checkbox" class="peer"> <label for="a" class="bg-gray-200 peer-checked:bg-blue-600">after the peer</label> <!-- does not work: the label comes before the peer --> <label for="b" class="bg-gray-200 peer-checked:bg-blue-600">before the peer</label> <input id="b" type="checkbox" class="peer">
after clicking both labels: both checkboxes: checked = true label after the peer: blue-600 (peer-checked applied) label before the peer: gray-200 (nothing happened)

Both checkboxes were checked; only the label that came after its peer reacted. This is the whole trick behind CSS-only toggles, tabs and accordions (Exercise 2). The group version has the same limit in the other direction: group-* can only style descendants, not ancestors or earlier siblings.

aria-* and data-*: Styling from Attributes

Many components expose their state as attributes: aria-expanded="true" on a menu button, data-state="open" on a dialog (headless component libraries do this). Tailwind turns those attributes into variants, so no JavaScript has to add and remove classes:

<div class="bg-gray-200 aria-checked:bg-blue-600 data-active:bg-green-600 data-[state=open]:bg-red-600"></div>
.aria-checked\:bg-blue-600[aria-checked="true"] .data-active\:bg-green-600[data-active] .data-\[state\=open\]\:bg-red-600[data-state="open"] element starts with none of the attributes: gray-200 after aria-checked="true": blue-600 after adding data-active: green-600 after adding data-state="open": red-600

The built-in aria- variants (I compiled aria-checked and aria-expanded; the official docs list more of the same kind) match the attribute set to "true"; for another value use brackets, as in aria-[sort=ascending]: ([aria-sort="ascending"]). data-active: matches the bare attribute being present, and data-[state=open]: matches a specific value. When several apply, the one that comes last in Tailwind's generated CSS wins, which is why the red one showed at the end.

Structural Variants: Position Among Siblings

VariantSelector
first: last: only::first-child, :last-child, :only-child
odd: even::nth-child(odd), :nth-child(even)
first-of-type::first-of-type
nth-3:, nth-[3n+1]::nth-child(3), :nth-child(3n+1)

Four list items with odd:bg-gray-100 even:bg-white first:rounded-t-lg last:rounded-b-lg measured: backgrounds gray-100, white, gray-100, white; the first item with an 8px radius at its top corners and the last with 8px at its bottom corners, and 0px elsewhere. This is zebra striping and rounded ends without a wrapper or extra classes.

Two variants style the children from the parent, so you do not repeat a class on every child: *: targets direct children (:is(.\*\:p-2 > *)) and **: targets all descendants. A <ul class="*:p-2"> gave each of its two items 8px of padding. A trade-off: the parent's rule is a more specific selector than the child's own class. With class="*:first:pt-0" on the list and pt-3 on each item, the first item measured 0px and the second 12px: the parent's variant overrode the child's own pt-3 where it applied.

Stacked variants read left to right in v4
You can combine variants (md:hover:bg-red-500, group-hover:md:underline), and they apply in the order written, left to right. This matters mainly for the structural ones, where order changes the meaning. The two classes below look alike and compile to different selectors:
*:first:pt-0 โ†’ :is(.\*\:first\:pt-0 > *):first-child (the first child of the list)
first:*:pt-0 โ†’ :is(.first\:\*\:pt-0:first-child > *) (the children of a list that is itself a first child)
The upgrade guide notes that v3 read stacked variants right to left, so v3 code written as first:*:pt-0 should be reversed to *:first:pt-0 in v4. Combinations of hover with breakpoints, such as md:hover: and hover:md:, nest the same two conditions in either order and behave the same.

has-*, not-* and in-*

has-* styles an element based on what it contains, which was impossible in CSS before :has(): has-checked:bg-blue-100 compiles to :has(:checked), and any selector goes in brackets, has-[:checked]: or has-[img]:. I made a <label class="has-checked:bg-blue-100"> around a checkbox: it had no background, and after I clicked the checkbox the whole label became blue-100. The same works from a group: group-has-[a]:block (compiled to :is(:where(.group):has(:is(a)) *)).

not-* is the negation: not-first:mt-2 is :not(:first-child), the classic "space between items but not before the first". For states it composes: not-hover:opacity-50 generated two rules, :not(:hover) and @media not (hover: hover), so the dimmed style applies on touch screens too. Measured: 0.5 at rest, 1 while hovered.

in-* is like group-* without the group marker: in-hover:opacity-100 compiles to :where(:hover) .in-hover\:opacity-100, "inside any hovered element". That "any" bites. Every page's <html> element is :hover while the pointer is anywhere over the page, so I measured an element with opacity-50 in-hover:opacity-100: 0.5 with the pointer outside the window, 1 with the pointer over an unrelated button (html:hover was true). For "this parent is hovered", use a named group. Treat in-hover: with suspicion; I did not test the other in-* variants, so check any you use against the same trap (does some ancestor, up to <html>, always match?).

Pseudo-elements

Variants also reach into the parts of an element that are not elements: before: and after: (::before, ::after), placeholder: (::placeholder), file: (::file-selector-button, the "Choose file" button), marker: (list bullets and numbers, applied to the element and its children), selection: (selected text), first-line:, first-letter: and backdrop: (a dialog's backdrop). For ::before and ::after you must give the pseudo-element some content, with the content-* class:

<span class="after:content-['*'] after:text-red-500">label</span> <span class="after:block after:size-4">no content utility</span>
.before\:content-\[\'\'\]::before { --tw-content: ''; content: var(--tw-content); } computed content of ::after: with after:content-['*'] "*" with only after:block (no content class) "" and display: block

The second line is a v4 convenience: the before: and after: variants add content: var(--tw-content) themselves and --tw-content defaults to an empty string, so after:block after:size-4 already creates a box (the measured content was ""). You only write content-['โ€ฆ'] when you want actual text. A placeholder:text-red-500 input measured the red on its placeholder colour.

The ! Modifier and Variant Order

To make one utility win over everything, put ! at the end of the class: bg-red-500! compiles to background-color: var(--color-red-500) !important, and with a variant, hover:bg-red-600/50!. In v3 the ! went at the start; the upgrade guide says the old position still works but is deprecated. Reach for it rarely: needing it usually means two rules are fighting that should not be.

Hands-On Exercises

Exercise 1

Style a primary button for six states: resting (blue-600), hover (blue-700), pressed (blue-800), a visible keyboard focus ring (2px outline, 2px offset), and disabled (half opacity, not-allowed cursor). Using real mouse and keyboard input, show which state appears in each case, and prove that a mouse click does not show the focus outline but Tab does. Then show that a disabled button still changes colour on hover, and fix it.

๐Ÿ“„ View solution
Exercise 2

Build a toggle switch with no JavaScript: a visually hidden checkbox, a rounded track that turns blue when checked, and a white knob that slides right by 20px. Show the state before and after a click (colour, knob position), that Tab and Space also work, and that the focus ring shows for the keyboard but not after a mouse click.

๐Ÿ“„ View solution
Exercise 3

Build a three-row table with zebra striping, a row highlight on hover, a row that turns blue when its checkbox is ticked, and an "Edit" button in each row that appears only when the row is hovered. Measure each state. Then check what a keyboard user sees: Tab to the second row's button and report its opacity, and make sure it is visible.

๐Ÿ“„ View solution

Chapter 6 Quick Reference

  • A variant is condition:utility; the same declaration is wrapped in a selector. hover: is inside @media (hover: hover) (no hover on touch); treat hover as an enhancement
  • States: hover active focus focus-visible focus-within disabled enabled checked required invalid read-only placeholder-shown visited open empty
  • Keyboard focus: use focus-visible: for buttons and links (a mouse click gives :focus but not :focus-visible); focus-within: on a wrapper for inputs
  • disabled controls still match hover: use enabled:hover:
  • group on a parent, group-hover: on descendants; name nested groups group/card and group-hover/card:. peer on an element, peer-checked: on LATER siblings only
  • aria-checked:/aria-expanded: (= "true"), aria-[sort=ascending]:, data-active: (attribute present), data-[state=open]: (specific value)
  • first: last: only: odd: even: nth-3: nth-[3n+1]:; *: direct children and **: all descendants (the parent's rule beats the child's own class)
  • Stacked variants read left to right in v4: *:first:pt-0 is not first:*:pt-0
  • has-checked:, has-[img]: (what it contains); not-first:, not-hover:; in-hover: matches ANY hovered ancestor, including <html>: use group instead
  • Pseudo-elements: before: after: placeholder: file: marker: selection: first-line: first-letter: backdrop:; content-['x'] only needed for text (empty content is the default)
  • ! goes at the end: bg-red-500!
Coming next
Tailwind CSS Fundamentals 7 uses the same variant mechanism for screen size: mobile-first breakpoints, max-* variants, container queries (@container), and designing a layout upwards from the smallest screen.