Skip to main content

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

StateTriggerPurpose
HoverMouse over elementIndicates interactivity
FocusKeyboard navigation / clickShows which element is active
ActiveMouse down / pressingConfirms the click is registering
DisabledElement unavailablePrevents 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

StateVisual changeTailwind classes
HoverBackground darkens 10%hover:bg-primary/90
FocusRing appears (keyboard only)focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50
ActiveScale down + darkenactive:bg-primary/80 active:scale-[0.98]
DisabledOpacity 50% + cursordisabled: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

StateVisual changeTailwind classes
HoverBorder darkenshover:border-muted-foreground/50
FocusRing appearsfocus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50
DisabledMuted background + opacitydisabled: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_states reports missingStates: ["focus"] for input code that uses only the focus-visible: prefix, and suggests "Add focus: classes for input". It detects the state from the prefix you wrote, and its tolerance works in one direction only: focus satisfies a required focus-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 use focus:".
  • get_color_guidance, asked about focus in a form-control context, puts focus-visible: on an input in its avoid list and composes its use examples with the focus: prefix.
  • validate_focus_management suggests restoring a removed outline with focus: 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 returns focus-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

StateVisual changeTailwind classes
HoverLift + shadowhover:shadow-md hover:-translate-y-0.5 transition-all
FocusRing appearsfocus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50
ActiveDepressactive:scale-[0.99] active:shadow-sm

Key rule: Non-interactive cards should have NO hover effect. This avoids false affordance.

Links

StateVisual changeTailwind classes
HoverUnderlinehover:underline
FocusRing appearsfocus-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

  1. Visual indicator: disabled:opacity-50
  2. Cursor feedback: disabled:cursor-not-allowed
  3. 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.

Was this page helpful?

Last reviewed by @jschuyler
All systems operationalDocsDevelopersPlatformPricing
PrivacyTerms© 2026 fndd, LLC