Redpanda UIRedpanda UI
Foundation

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.

Loading component...

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 whenUnderline
link-standaloneContext already says it is a link — breadcrumbs, nav items, clickable labelsReveals on hover and focus
link-inlineThe link sits inside a sentence, where only colour separates it from the words either sidePermanent, 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

FamilyTakes a state suffix?
surface-<tone>, primary, secondary, brand, selectedAlways, 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>-lineAlways, all three — an outline is the whole shape of a control, so every tone carries the full ramp
<tone>-strong, groundsNever — 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 becausecontrols
    the state change is the feedbackcheckbox, radio, switch, choicebox · tabs · navigation-menu · input, textarea · editable-text
    navigating away is the feedbackbreadcrumb, badge-as-link, prose links
    pressing would spoil somethingtable rows — a pressed fill flashes through a text-selection drag
    the portal takes the pointera Base UI Select trigger stops matching :active the 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 pairs group-hover: with group-focus-within:.

  • A state scoped off another state says sohover: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 in source-conventions.test.ts with 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-all is 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/90 fades 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 are rgba().

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.

Built by malinskibeniamin. The source code is available on GitHub.

On this page