Interaction
How DrumKit components respond to being hovered, focused, pressed, and selected, and how those responses differ depending on whether someone is using a mouse, a finger, or a keyboard.
The three layers
DrumKit does not define its own interaction states. It inherits them: React Aria decides when a state is active and exposes it to styling; DrumKit decides what it looks like. This split is the most important thing on this page: the correct answer to what states a component has is never whichever ones we decided to draw; it is whatever React Aria provides for that component.
Layer | Owns | You change this |
|---|---|---|
| React Aria Components | Behavior: what counts as a hover, a press, a focus | No |
| Untitled UI | Default styling and component API | Rarely |
| DrumKit | Tokens and visual treatment | Yes |
Rule
We style states, we don’t invent them. If the state you want isn’t in the vocabulary below, that’s a signal to reconsider the design, not to add a class.
Interaction states
Six states carry nearly all the weight in DrumKit. Design every interactive element against all six before it ships. Beyond these, individual components expose their own states (disabled, invalid, read-only, required, indeterminate, dragging, drop target, and others), with availability varying by component; the Styling section of each React Aria component’s documentation is the authoritative list.
Design
State | What it tells the user | How to design it |
|---|---|---|
| Rest | This is here, and it’s interactive | Legible with no interaction at all; hover can’t be what reveals it |
| Hover | Your pointer is on the target | Subtle. It confirms aim, nothing more |
| Focus | This element is receiving input | Distinct from the ring, like an active input border |
| Focus visible | You are here, and you got here by keyboard | Unmissable. A navigation aid, not decoration |
| Pressed | Your action registered | Immediate, for click, tap, and Enter/Space alike |
| Selected | This is chosen, and stays chosen | Persistent. Survives hover and focus without going ambiguous |
Live from the kit: hover, tab through, press, and toggle to watch the states
Designing state combinations. States stack. An item can be selected and hovered and focus-visible at the same time. Design the combinations, not just the singles; the common failure is a selected item whose hover treatment makes it read as unselected.
Precedence in DrumKit:
- Disabled overrides everything. A disabled element receives neither hover nor press. Do not design a disabled-hover treatment; it will never render.
- Selected is the base for its own hover and press. Style hover-on-selected explicitly rather than letting the unselected hover treatment apply.
- Focus visible composes, it doesn’t replace. Build the focus ring as an outline so it layers over whatever else is happening rather than competing with the background treatment.
Code
DrumKit styles states in two layers, and the split is the house convention.
Native Tailwind variants are the default. Because React Aria renders real elements, hover:, focus-visible:, and disabled: work directly; the kit uses them hundreds of times, and focus-visible: natively keeps rings off clicked buttons.
<Button className="
bg-brand-solid text-white
hover:bg-brand-solid_hover
focus-visible:outline-2 focus-visible:outline-offset-2
disabled:cursor-not-allowed disabled:opacity-50
">
Save changes
</Button>React Aria’s state variants fill the gaps CSS can’t express. The tailwindcss-react-aria-components plugin is installed (via @plugin in globals.css), so every React Aria state is available as a variant: selected:, pressed:, hovered:, indeterminate:, and the rest. Reach for them when there is no native equivalent (selection), when the native pseudo-class misbehaves (:active is inconsistent and misses keyboard activation; use pressed:), or when touch behavior matters (native :hover can stick after a tap; hovered: never applies on touch).
<ToggleButton className="
bg-primary text-secondary
hover:bg-primary_hover
selected:bg-active selected:text-secondary_hover
pressed:bg-primary_hover
">
Bold
</ToggleButton>Render props when one state drives several classes. Use them when one state needs to drive several classes at once, or when logic gets conditional.
<Radio className={({ isSelected, isFocusVisible }) => `
flex rounded-lg p-4 ring-1
${isSelected ? "ring-brand bg-brand-primary_alt" : "ring-secondary"}
${isFocusVisible ? "outline-2 outline-offset-2" : ""}
`} />The full state-by-state reference (which variant to write and which tokens it draws on) lives in Writing states below.
Input modality
The same component offers different states depending on how it’s being operated. This is the part most interaction documentation gets wrong, and it has direct design consequences.
Design
Modality | Hover | Focus ring | Press |
|---|---|---|---|
| Mouse / trackpad / pen | Yes | No | Yes |
| Touch | No | No | Yes |
| Keyboard | No | Yes | Yes |
| Screen reader | No | Yes | Yes |
Hover does not exist on touch
The focus ring is keyboard-only, by design
Press is the one universal
Touch target sizing lives on the Accessibility page.
Code
You do not need to detect modality yourself. React Aria resolves it before any state reaches your styles:
Never applied on touch
Keyboard and screen reader only
focused: is the looser one: it applies on any focus, including a mouse click.Every input, one state
Writing states
The state-by-state reference: the variant you write and the tokens it draws on. Every state treatment resolves to a token: a raw Tailwind palette value (bg-blue-600) in a state variant is a bug; it won’t respond to theming and it won’t switch in dark mode. Cursors are part of the idiom too: cursor-pointer on interactive elements, cursor-not-allowed on disabled ones.
State | You write | Tokens it draws on |
|---|---|---|
| Rest | base classes | bg-primary · text-primary · border-primary families |
| Hover | hover: (hovered: on touch-critical surfaces) | the _hover family: bg-primary_hover, bg-brand-solid_hover |
| Focus visible | focus-visible:outline-2 + offset-2 | --color-focus-ring (brand-500) |
| Pressed | pressed: | reuses the _hover family |
| Selected | selected: | bg-active + the _hover family |
| Invalid | invalid: / group-invalid: | ring-error(_subtle) · focus-ring-error (rose-500) |
| Disabled | disabled: | opacity-* + cursor-not-allowed (no dedicated tokens) |
- Invalid pairs with the error message. Inputs show
ring-error_subtleat rest andring-error ring-2while focused; theFieldErrorslot says what’s wrong and how to fix it, never color alone. - Disabled is not read-only. A disabled control is skipped by keyboards and screen readers and its text can’t be selected. If someone should still perceive and copy the value (they can’t change it), use
readOnly, notisDisabled.
Checklist
Before a component is considered done:
- All six core states designed, including rest
- Interactivity is legible at rest, without hover
- No affordance, label, or control is hover-only
- Focus ring present via
focus-visible:, never suppressed - Focus ring meets 3:1 contrast against adjacent colors
- Press state fires for click, tap, and Enter/Space
- Selected + hover and selected + focus-visible both designed
- Disabled state does not rely on color alone
- States use the house layers: native variants by default, React Aria variants (
selected:,pressed:) where CSS can’t - Every state treatment resolves to a DrumKit token
- Verified on a real touch device, not a simulator
Reference
The authoritative sources this page leans on.
- React Aria: Styling (the authoritative state list, per component)
- DrumKit Motion (timing, easing, transitions)
- DrumKit Accessibility (keyboard navigation, targets, screen readers)