Skip to content

Interaction States

The three-state interaction model for TUI-style components — highlight, selected, and hover.

Three-state model

@diffgazer/ui components use three distinct interaction states inspired by terminal UIs (the VS Code list model):

StateTriggerVisual WeightImplementation
HighlightKeyboard arrows only (@diffgazer/keys)Strongestbg-primary text-primary-foreground font-bold + glyph via data-highlighted
SelectedPersistent choice (Enter/click)Strongest when enabledbg-primary text-primary-foreground font-bold
HoverReal pointer travelSubordinatebg-secondary + brightened label + chevron glyph via data-hovered

The keyboard owns the cursor: only arrow keys (and typeahead) move the highlight and aria-activedescendant, and only that cursor fires onHighlightChange. The pointer owns a separate, purely cosmetic hover. Clicking still commits — it moves the cursor and activates the item — but travelling never does, so reading the menu with the mouse can never yank the keyboard cursor around.

Hover is deliberately not CSS :hover. It is JS state set by pointermove events gated on real coordinate deltas — an event whose clientX/clientY equal the previous ones is ignored, which kills stationary-cursor artifacts (browser :hover sticks to a resting pointer, and Safari re-fires synthetic pointermoves on re-render). The hover clears on pointerleave and on any navigation keydown, and re-arms only when the pointer actually travels again. A hovered row shows a right-pointing chevron in the indicator slot; the glyph belongs exclusively to the keyboard cursor, and when a row is both hovered and highlighted the keyboard treatment wins. Idle rows render an empty indicator slot. The chevron reveal is a conditional mount inside a fixed-width w-5 slot: the slot always occupies the same width, so revealing the chevron never shifts the label column, and the glyph appears instantly rather than fading in — the TUI-appropriate treatment. The delta gate also means a scroll under a stationary pointer would not move the hover to the new row beneath it (no pointerleave fires, and the resting-cursor re-fire is exactly what the gate blocks); no current menu surface scrolls, but a scrollable variant must clear the hover in its container's onScroll, the same one-line pattern as the navigation-keydown clear. Rows that already carry a directional glyph — the submenu trigger's trailing chevron, the back row's — take the hover background only, never a second chevron.

State precedence

plaintext
disabled focused > disabled > highlight (focused) > selected > hovered > normal

This comes from getItemState() in menu-item-variants.ts. A disabled item that owns the menu highlight renders disabledFocused, so APG-style disabled menu items can still be the keyboard cursor without looking enabled. Otherwise, disabled wins over selection — and over hover: disabled rows never take hover styling. A highlighted enabled item always shows highlight visuals even if it is also the selected or hovered item. This ensures the keyboard cursor is always visible.

The same disabledFocused state applies to submenu triggers. Disabled submenu triggers and checkbox items keep data-highlighted when they own the virtual focus, while their disabled styling and activation guards remain in effect.

Visual mapping (CVA variants)

The menuItemBase CVA maps each state to Tailwind classes:

CVA stateClassesPurpose
"normal"(none)Idle row; empty indicator slot
"focused"font-bold + compound variant bg-primary text-primary-foregroundThe keyboard cursor
"selected"font-bold + compound variant bg-primary text-primary-foregroundPersistent selected item when selection is enabled
"hovered"bg-secondary text-foregroundSubordinate pointer affordance, driven by data-hovered state, never CSS :hover
"disabled"opacity-50 cursor-not-allowedGreyed out, no pointer interaction
"disabledFocused"opacity-60 cursor-not-allowed bg-secondary text-foregroundDisabled item that still owns keyboard cursor in menu patterns without looking enabled

Danger items use bg-error text-error-foreground for both focused and selected states, and keep their text-error palette under hover.

Sync on click

When selection is enabled (selectedId or defaultSelectedId), activating a MenuItem sets both selection and highlight. Plain command menus do not persist selected state; activation calls onSelect and leaves the keyboard highlight model in charge of the active item.

autoFocus

Pass autoFocus to Menu when it is the primary interaction target on the page. This focuses the container div on mount so keyboard events (ArrowUp/Down, Enter) work immediately without requiring the user to click or tab into the menu first.

tsx
import { Menu, MenuItem } from "@/components/ui/menu"

<Menu
  selectedId={selected}
  highlighted={highlighted}
  onHighlightChange={setHighlighted}
  onSelect={handleSelect}
  autoFocus
  aria-label="Main menu"
>
  <MenuItem id="item-1">First Item</MenuItem>
  <MenuItem id="item-2">Second Item</MenuItem>
</Menu>

Diffgazer web persists menu highlight state across route changes as an app-level pattern. The direction of navigation determines whether the highlight resets or restores:

  • Forward (parent → child): Parent clears the child's stored highlight before navigating. The child menu starts from its first item.
  • Back (child → parent): Parent's highlight is restored automatically from the persistent store.
tsx
import { useEffect, useState } from "react"

function scopedRouteStateKey(route: string, key: string) {
  return `route-state:${route}:${key}`
}

function readScopedRouteState<T>(route: string, key: string, fallback: T): T {
  if (typeof window === "undefined") return fallback
  const raw = window.sessionStorage.getItem(scopedRouteStateKey(route, key))
  return raw ? (JSON.parse(raw) as T) : fallback
}

function writeScopedRouteState(route: string, key: string, value: unknown) {
  window.sessionStorage.setItem(scopedRouteStateKey(route, key), JSON.stringify(value))
}

function clearScopedRouteState(route: string, key: string) {
  window.sessionStorage.removeItem(scopedRouteStateKey(route, key))
}

// Persist highlight — restores when returning from a child route
const currentRoute = "/settings"
const [highlighted, setHighlighted] = useState<string | null>(() =>
  readScopedRouteState(currentRoute, "highlighted", items[0]?.id ?? null)
)

useEffect(() => {
  writeScopedRouteState(currentRoute, "highlighted", highlighted)
}, [highlighted])

// When navigating forward, clear the child's highlight so it starts fresh
const handleActivate = (id: string) => {
  const route = routes[id]
  if (route) {
    clearScopedRouteState(route, "highlighted")
    navigate({ to: route })
  }
}

This gives TUI-like behavior: entering a menu always starts from the top, but returning to it picks up where you left off.

Accessibility

The interaction state model maps directly to ARIA semantics:

  • Container: role="menu", tabIndex={0}, aria-activedescendant pointing to the highlighted item
  • Items: role="menuitem", data-value={id}, data-highlighted for keyboard/virtual focus, data-selected for selection, data-hovered for the cosmetic pointer hover, aria-disabled for disabled items
  • Selectable menu items: role="menuitemradio" with aria-checked for the selected item
  • Screen readers: announce the active descendant as the user arrows through items -- the highlight state drives aria-activedescendant
  • Follows WAI-ARIA Menu Pattern (APG)

The model maps cleanly onto the APG listbox pattern: aria-activedescendant always reflects the keyboard cursor and nothing else. Hover is cosmetic and never enters the accessibility tree, so what a screen reader announces is exactly what the keyboard user sees, while aria-checked reflects selection only when the menu is explicitly selectable.