---
title: Interaction states
description: How hover, pressed and focus are built — three archetypes chosen by rest appearance, and the naming contract that keeps them from silently failing.
icon: mouse-pointer-click
---

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.

<ComponentPreview name="interaction-states-demo" />

## A · Fill step

```tsx
'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:

```tsx
'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.

```tsx
// 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 |

:::warning
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:

```tsx
'!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 `: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 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 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](https://redpanda-ui-playground.netlify.app/interaction-states) and `interaction-states.spec.ts` drives every specimen at rest, hovered, held and keyboard-focused in both themes, asserting the computed value actually moved.

:::warning
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.
:::
