Interaction states
How hover, pressed and focus are built — three archetypes chosen by rest appearance, and the naming contract that keeps them from silently failing.
Which treatment a control gets is decided by what it looks like at rest, not by what kind of component it is. Does it already have a fill? Step that fill. Is it transparent? Lay a tint over it. Is it an outline or plain text? Step the colour of that line or ink.
A · Fill step
'bg-primary hover:bg-primary-hover active:bg-primary-pressed'Every reactive fill — primary, secondary, brand, surface-<tone>, surface-subtle, selected — has a -hover, and a -pressed wherever something presses it.
B · Tint in
The control is transparent, so hover lays a translucent tint over whatever it sits on. One token backs the highest-traffic hover in the product — menu items, table rows, list rows, ghost buttons:
'hover:bg-accent hover:text-accent-foreground active:bg-accent-pressed'accent is a near-neutral wash, pitched lower than the opaque steps because a row is a wide target and needs less contrast to register. Being translucent, it adapts to a page, a card or a popover with no variant per ground.
Semantic rows tint from their own family instead — hover:bg-destructive-wash active:bg-destructive-wash-pressed. The sidebar keeps its own sidebar-accent pair, because it is a dark surface in both themes and cannot tint from the page ground.
C · Edge step
Hover steps the line or ink colour and leaves the fill alone. Geometry never changes.
// Outline buttons, selectable cards
'!border-primary-line hover:!border-primary-line-hover active:!border-primary-line-pressed'
// Any tone, the same shape — every `<tone>-line` carries all three rungs, so an
// outlined status control needs no borrowed ramp and no opacity shortcut
'!border-warning-line hover:!border-warning-line-hover active:!border-warning-line-pressed'
// Form controls. `input-fill` is transparent on a light page and a wash of the
// border colour in dark, so one pair covers both themes with no `dark:`
'!border-input bg-input-fill hover:not-disabled:!border-input-hover hover:not-disabled:bg-input-fill-hover'
// Draggable hairlines — scrollbar thumbs, resize handles
'bg-border hover:bg-border-hover active:bg-border-pressed'Lines take a larger lightness step than fills: a 1px border shows far less colour than a filled rectangle, so matching the fill step reads as no change.
A line is also the whole boundary of an outline control, with no fill behind it to say where the control ends — so every light <tone>-line clears 3:1 against the page, the floor WCAG 1.4.11 sets for identifying a component. A pastel that reads well as a hairline inside a tinted Alert is not enough on white.
Form controls are the one place C pairs with B — the border does the work and input-fill-hover lays a faint wash so the whole field responds. Checkbox and radio drop the wash once checked, since they already carry a solid fill.
Interactive text
Colour is the only thing hover can change on text, and on a short word it is easy to miss. Two utilities put the underline back as the affordance:
| Use when | Underline | |
|---|---|---|
link-standalone | Context already says it is a link — breadcrumbs, nav items, clickable labels | Reveals on hover and focus |
link-inline | The link sits inside a sentence, where only colour separates it from the words either side | Permanent, firms on hover |
link-inline is an accessibility requirement, not a preference: WCAG 1.4.1 rejects colour as the only visual means of conveying information. Both reserve the underline in the box at rest and animate text-decoration-color, which transition-colors already covers, so the line fades in with no reflow.
The naming contract
| Family | Takes a state suffix? |
|---|---|
surface-<tone>, primary, secondary, brand, selected | Always, all three — a solid fill is what a button or a toggle is made of, so it carries the full ramp whether or not the registry itself presses it yet |
<tone>-wash | -pressed only — a tint rests transparent, so its base value is the hover |
<tone>-line | Always, all three — an outline is the whole shape of a control, so every tone carries the full ramp |
<tone>-strong, grounds | Never — a status dot and a ground do not react |
accent, sidebar-accent | -pressed only |
Tailwind emits nothing for a utility naming a token that does not exist — no error, no warning. A hover written against a plausible but wrong name is invisible until someone notices the control does not react.
bun run tokens:check gates registry:build on that, plus a state outranked by an !important base. Some rest borders are !important deliberately, so they survive a consumer's own classes — which means every state on the same property needs the same weight:
'!border-primary-line hover:border-primary-line-hover' // base wins; hover never renders
'!border-primary-line hover:!border-primary-line-hover' // correctRules
-
Everything a pointer can act on reacts. Not necessarily with a fill — a tab steps its ink, a chip steps its own. Silence is what is not allowed.
-
Hover implies pressed, one rung further, unless the click itself is the feedback:
exempt because controls the state change is the feedback checkbox, radio, switch, choicebox · tabs · navigation-menu · input, textarea · editable-text navigating away is the feedback breadcrumb, badge-as-link, prose links pressing would spoil something table rows — a pressed fill flashes through a text-selection drag the portal takes the pointer a Base UI Select trigger stops matching :activethe moment it opens, so the listbox appearing is the feedback; its scroll arrows are held, not clicked -
Hover never carries information alone. Anything it reveals is reachable through
focus-visible; every reveal-on-hover pairsgroup-hover:withgroup-focus-within:. -
A state scoped off another state says so —
hover:not-data-[active]:on a tab,hover:not-disabled:on a field. -
One focus ring.
focus-visible:!border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50, hue-swapped for destructive controls and the sidebar. Deviations are listed insource-conventions.test.tswith what marks focus instead. -
One transition.
transition-colors motion-reduce:transition-none. The duration is Tailwind's own 150ms default, so no call site restates it — pinning it locally would opt that control out of any later change to the default. A fading focus ring names its properties (ring-*is a box-shadow);transition-allis only for the few that also move. The escape has to sit on the same element as the transition: a[&>…]variant moves the transition onto a descendant, and a bare escape beside it disables nothing. -
Never fake a ramp with opacity.
hover:bg-primary/90fades an opaque fill toward whatever is behind it, so it reads differently on every ground and cannot be retuned centrally. Alpha is fine when translucency is the design — the archetype-B tints arergba().
Values and testing
theme.css holds the real values; each -hover sits directly under its rest token. bun run tokens:states derives them from the rest token in OKLCH and writes them in place — a bootstrap and refresh tool, not an owner. tokens:check reports divergence from the formula without failing, because a hand-tuned hover is a design decision.
A screenshot of a page at rest cannot catch a hover regression, so the playground carries an interaction-states bench and interaction-states.spec.ts drives every specimen at rest, hovered, held and keyboard-focused in both themes, asserting the computed value actually moved.
Static checks prove a class compiles, not that its trigger ever fires. Menus are where
that bites: an item's highlight is driven by JS state, not by :hover. Base UI sets
data-highlighted for the pointer and for arrow keys alike, so that one hook is the whole
reaction — while focus: fires for neither, because a menu item never becomes
document.activeElement. Adding hover: beside it buys nothing and costs the pressed step:
data-[highlighted]: sorts after active: at equal specificity, so a highlighted item
never shows one. Menu items take no pressed step for that reason — closing the menu is the
feedback. menu-highlight.spec.ts asserts an item answers the pointer and the keyboard
with the same single wash, in both themes.