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:
'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. So do Button’s ghost and dashed families, each tinting from the tone it carries. 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-secondary-line hover:!border-secondary-line-hover active:!border-secondary-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 white wash
// 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 |
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' // correct
Rules
-
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.