Interaction states
Interaction states define how UI elements visually respond to user actions: hover, focus, active (pressed), and disabled. Consistent interaction states help users understand what's clickable, what's focused, and what's unavailable.
The 4 core states
| State | Trigger | Purpose |
|---|---|---|
| Hover | Mouse over element | Indicates interactivity |
| Focus | Keyboard navigation / click | Shows which element is active |
| Active | Mouse down / pressing | Confirms the click is registering |
| Disabled | Element unavailable | Prevents interaction |
State patterns by component
A note before the tables: the ring's width, colour, and whether it carries an
offset are the design system's to declare, so don't treat the values below as a
universal constant to copy. The examples in this page show Museum's declared
ring (focus-visible:ring-1 focus-visible:ring-foreground/50, no offset) for
concreteness; always confirm your own design system's values with
get_interaction_states, or with get_design_tokens for the token values,
rather than hand-copying these. get_focus_pattern is a weaker source for this
and may be no source at all: its shape is focus management behaviour plus
focusRing.focusVisibleOnly (a boolean) and focusRing.style, which is passed
through from whatever your design system set as its focus ring style. There is no
width, colour or offset field, so what you get back depends entirely on what was
declared. Asked for componentType: 'input' against Museum it returns no
patterns at all (measured 2026-10-09); a design system that declares an input
focus pattern will get that row back instead.
If your resolved row declares an offset deliberately, that's what's correct to
ship for your design system. Museum itself doesn't use one: the ring is a
box-shadow, so an offset moves its effective radius away from the element's
own corner, and an un-themed offset colour shows as a white halo in dark mode.
Weigh that tradeoff for your own system rather than assuming offset is
universally forbidden.
Buttons
| State | Visual change | Tailwind classes |
|---|---|---|
| Hover | Background darkens 10% | hover:bg-primary/90 |
| Focus | Ring appears (keyboard only) | focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50 |
| Active | Scale down + darken | active:bg-primary/80 active:scale-[0.98] |
| Disabled | Opacity 50% + cursor | disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none |
Key rule: Use focus-visible: instead of focus: for buttons. This shows the focus ring only for keyboard users, not mouse clicks.
Inputs
| State | Visual change | Tailwind classes |
|---|---|---|
| Hover | Border darkens | hover:border-muted-foreground/50 |
| Focus | Ring appears | focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50 |
| Disabled | Muted background + opacity | disabled:opacity-50 disabled:bg-muted disabled:cursor-not-allowed |
Key rule: Inputs use the same focus-visible: prefix as every other
focusable element, never the always-on focus: prefix, which shows the ring
on mouse clicks too. get_interaction_states keys the input row's state as
focus (that's the row's identity in the data model: inputs need their
focus state indicated reliably), but that key is not an instruction to write
focus: classes. The Tailwind prefix you write is still focus-visible:; the
key and the prefix answer different questions.
Several tool responses still prescribe focus: for inputs, and will tell you so.
They are known and tracked; none of them is a reason to write focus:. They also
share a single root: validate_interaction_states lists focus in requiredStates
for input and select, and the others cite that requirement as their justification.
So this is one disagreement reported in several places, not several independent ones.
validate_interaction_statesreportsmissingStates: ["focus"]for input code that uses only thefocus-visible:prefix, and suggests "Addfocus:classes for input". It detects the state from the prefix you wrote, and its tolerance works in one direction only:focussatisfies a requiredfocus-visible, but not the reverse. The resolved input row says the same thing in its own guidance: "a known validator inconsistency, not a reason to usefocus:".get_color_guidance, asked about focus in a form-control context, putsfocus-visible:on an input in itsavoidlist and composes itsuseexamples with thefocus:prefix.validate_focus_managementsuggests restoring a removed outline withfocus:on inputs and selects.- If your design system has no declared focus doctrine, the entailed default
you receive also says inputs take
focus:. That default is what a tenant gets in the absence of a declaration; a declared row overrides it, which is why Museum's resolved row returnsfocus-visible:above.
This list names the responses we know disagree today, and is not a guarantee that
no other response does. Confirm against your own resolved row
(get_interaction_states), which is authoritative for your design system, rather
than against any guidance that contradicts it.
Interactive cards
| State | Visual change | Tailwind classes |
|---|---|---|
| Hover | Lift + shadow | hover:shadow-md hover:-translate-y-0.5 transition-all |
| Focus | Ring appears | focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50 |
| Active | Depress | active:scale-[0.99] active:shadow-sm |
Key rule: Non-interactive cards should have NO hover effect. This avoids false affordance.
Links
| State | Visual change | Tailwind classes |
|---|---|---|
| Hover | Underline | hover:underline |
| Focus | Ring appears | focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50 |
Global rules
1. Hover feedback should be immediate
Hover states need instant feedback (under 50ms). Use transition-colors with short duration.
// Good — fast transition
className="transition-colors duration-50 hover:bg-primary/90"
// Bad — too slow
className="transition-all duration-300 hover:bg-primary/90"
2. Use focus-visible, not focus
For every focusable element, including inputs, write the focus-visible: prefix, never the always-on focus: prefix as a component type's default. For most elements this shows the ring only for keyboard navigation; text-editable inputs are a browser heuristic exception (see the FAQ) and can still show the ring on a pointer click without you writing focus: yourself. Reach for focus: only as a deliberate, documented exception.
// Good — keyboard users only see ring
className="focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50"
// Bad — mouse users see ring on every click
className="focus:ring-1 focus:ring-foreground/50" // always-on prefix, wrong
3. Disabled states need three things
- Visual indicator:
disabled:opacity-50 - Cursor feedback:
disabled:cursor-not-allowed - Prevent interaction:
disabled:pointer-events-none
// Complete disabled state
className="disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
// Incomplete — hover effects still work
className="disabled:opacity-50 disabled:cursor-not-allowed"
4. Keep hover effects subtle
Hover changes should be 5-10% opacity/lightness shifts, not dramatic transforms.
// Good — subtle
className="hover:bg-primary/90" // 10% darker
// Bad — too dramatic
className="hover:scale-110 hover:rotate-2"
MCP tool usage
Getting interaction states
// Get all states for a button
get_interaction_states({ component: 'button' })
// Get specific state
get_interaction_states({
component: 'button',
variant: 'primary',
state: 'hover'
})
// Include rules for learning
get_interaction_states({
component: 'input',
includeRules: true
})
Validating implementation
// Check if a component has all required states
validate_interaction_states({
code: '',
component: 'button'
})
// Returns missing states, violations, and recommendations
Common mistakes
Using focus instead of focus-visible
// Problem: Ring shows on mouse click
// Solution: Ring only on keyboard focus
This applies to inputs too: the always-on focus: prefix is not the input
convention. See Inputs above.
Incomplete disabled states
// Problem: Hover still works when disabled
// Solution: Disable pointer events
Static cards with hover effects
// Problem: Card looks clickable but isn't
Content
// Solution: No hover on non-interactive elements
Content
FAQ
When should I use focus: vs focus-visible:?
Almost always focus-visible:, for buttons, links, cards, tabs, and
inputs. For most elements it shows the ring only for keyboard navigation.
Text-entry controls are a browser heuristic exception, and only those. For
a text field (input of type text, email, password, search, url,
tel, number), a textarea, or a contenteditable element, user agents
match :focus-visible even after a pointer click, because a field that accepts
typing needs to show where the text will go however you got there. So a text
input written with focus-visible: can still show its ring on click, without
you writing focus:.
The heuristic does not extend to every form control. A <select> is not a
text-entry control, and neither are input type="checkbox", "radio",
"range", "button" or "file". On those, clicking may produce no ring,
which is the intended behaviour: the click itself is the feedback. Verify in
your target browsers before relying on a click-visible ring for a non-text
control, rather than assuming this paragraph covers it.
None of this is a reason to reach for the always-on focus: prefix yourself.
Reach for focus: only for a deliberate, documented exception, never as a
component type's default.
Note that get_interaction_states keys some rows (inputs, for example) with
the state name focus. That key names when the row applies in the data
model, not an instruction to write focus: classes. Check the row's
declared Tailwind classes, not its key, for which prefix to use.
Should all interactive elements have all four states?
Not necessarily:
- Buttons: hover, focus-visible, active, disabled
- Inputs: hover, focus-visible, disabled (no active needed)
- Links: hover, focus-visible (active optional)
- Static cards: none (they're not interactive)
How dark should hover be?
10% is the sweet spot. Use /90 opacity in Tailwind:
hover:bg-primary/90(10% darker)hover:text-primary/90(10% darker text)
Going darker (e.g., /70) is too dramatic for most cases.