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.
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:
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:
| Variant | Selector it generates | When it applies |
|---|---|---|
| hover: | :hover inside @media (hover: hover) | pointer over the element, on devices that can hover |
| active: | :active | while pressed |
| focus: | :focus | the element has focus, however it got it |
| focus-visible: | :focus-visible | focus the browser decides should be shown (below) |
| focus-within: | :focus-within | the element or anything inside it has focus |
| disabled: enabled: | :disabled, :enabled | form controls |
| checked: | :checked | checkboxes, radios, options |
| required: invalid: read-only: | :required, :invalid, :read-only | form validation and editability |
| placeholder-shown: | :placeholder-shown | an input still showing its placeholder |
| visited: | :visited | links already followed |
| open: | :is([open], :popover-open, :open) | a <details>, popover or dialog that is open |
| empty: | :empty | no 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-visible | Which class applied |
|---|---|---|---|
| Click the button with the mouse | true | false | focus: (yellow) |
| Press Tab to reach the button (fresh page) | true | true | both; focus-visible: (red) came last, so it wins |
| Click into the text input | true | true | both (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: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:
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:
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:
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
| Variant | Selector |
|---|---|
| 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.
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:
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
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.
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 solutionBuild 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 solutionChapter 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:focusbut not:focus-visible);focus-within:on a wrapper for inputs disabledcontrols still matchhover: useenabled:hover:groupon a parent,group-hover:on descendants; name nested groupsgroup/cardandgroup-hover/card:.peeron an element,peer-checked:on LATER siblings onlyaria-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-0is notfirst:*:pt-0 has-checked:,has-[img]:(what it contains);not-first:,not-hover:;in-hover:matches ANY hovered ancestor, including<html>: usegroupinstead- 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!
max-* variants, container queries
(@container), and designing a layout upwards from the smallest screen.