gridland
Hooks

useInteractive

Compose focus registration, selection-scoped keyboard routing, and shortcut hints in one hook

The primary hook for focus-aware components. useInteractive composes three things that would otherwise be three separate hook calls: focus registration with the FocusProvider, selection-scoped keyboard routing (handlers that fire only while the component is entered), and shortcut hint registration for StatusBar integration.

For the full conceptual model FocusProvider, FocusScope, navigation, the 4-state border affordance, and the styled variant see Focus. This page is the standalone API reference.

Import
import { useInteractive } from "@gridland/utils"

Usage

Minimal
function Cell({ id, label }: { id: string; label: string }) {
  const interactive = useInteractive({ id })

  interactive.onKey((e) => {
    if (e.name === "return") console.log("submitted", label)
  })

  return (
    <box ref={interactive.focusRef} border>
      <text>{label}</text>
    </box>
  )
}
With shortcuts and themed border
import { useInteractive } from "@gridland/utils"
import { useFocusBorderStyle } from "@/lib/theme"

function Cell({ id, label }: { id: string; label: string }) {
  const interactive = useInteractive({
    id,
    shortcuts: ({ isSelected }) =>
      isSelected
        ? [{ key: "enter", label: "submit" }, { key: "esc", label: "back" }]
        : [{ key: "↑↓←→", label: "navigate" }, { key: "enter", label: "select" }],
  })

  const { borderColor, borderStyle } = useFocusBorderStyle({
    isFocused: interactive.isFocused,
    isSelected: interactive.isSelected,
    isAnySelected: interactive.isAnySelected,
  })

  interactive.onKey((e) => {
    if (e.name === "return") console.log("submitted", label)
  })

  return (
    <box ref={interactive.focusRef} border borderStyle={borderStyle} borderColor={borderColor}>
      <text>{label}</text>
    </box>
  )
}

Parameters

useInteractive accepts a single options object. All fields are optional.

OptionTypeDefaultDescription
idstringauto-generated via useId()Stable focus id. Pass an explicit id when another component needs to share it (display wrappers) or when tests need to target it.
autoFocusbooleanfalseFocus this component on mount.
disabledbooleanfalseRemove this component from the tab cycle. Still renders, but cannot receive focus.
selectablebooleantrueWhether pressing Enter selects the component for interaction. Set to false for components that should focus but never enter a selected state.
tabIndexnumber0Tab order. -1 removes from the tab cycle (equivalent to disabled for navigation purposes, but the component can still be focused imperatively).
scopeIdstring | nullinherited from enclosing FocusScopeOverride the enclosing scope. undefined uses the context scope. null opts out of any enclosing scope and routes to the root used for modals-within-modals, portals, and headless composition.
shortcutsShortcutEntry[] | ((state) => ShortcutEntry[])[]Shortcut hints surfaced via useFocusedShortcuts while this component is focused. The function form receives { isFocused, isSelected } and is re-evaluated every render, so you can return different hints for focused vs. selected states.

Returns

PropertyTypeDescription
focusRef(node: any) => voidRef callback. Attach to the component's root <box> so spatial navigation can measure positions.
focusIdstringThe stable focus id (provided or auto-generated). Use this when passing to other hooks like useKeyboard directly.
isFocusedbooleanTrue when this component has keyboard focus.
isSelectedbooleanTrue when this component is selected (entered for interaction via Enter).
isAnySelectedbooleanTrue when any component in the focus tree is selected. Scope-aware stays true when the selection is saved behind a FocusScope, so sibling borders correctly hide. Used by useFocusBorderStyle to drive the sibling-selected state.
onKey(handler: (event: KeyEvent) => void) => voidRegister (or replace) the keyboard handler that fires while this component is selected. See onKey below.
focus() => voidImperatively focus this component.
blur() => voidImperatively blur this component.
select() => voidImperatively select (enter) this component.
deselect() => voidImperatively deselect the currently selected component.

onKey

onKey uses a ref swap. Call it every render with a fresh closure the underlying keyboard listener is registered once per mount and replaces the stored handler on each call. This means you can close over component state in the handler without worrying about stale closures or re-subscription.

const interactive = useInteractive({ id: "panel" })
const [count, setCount] = useState(0)

// Fresh closure every render reads the latest `count` without re-subscribing.
interactive.onKey((e) => {
  if (e.name === "return") setCount(count + 1)
})

The handler fires only while the component is selected (focused and entered via Enter). For global handlers or handlers that should fire while only focused not selected reach for useKeyboard directly.

shortcuts: function form

Pass a function when the shortcut hints should differ between focused and selected states. The function is re-evaluated every render, so it always reflects the latest state.

useInteractive({
  id,
  shortcuts: ({ isFocused, isSelected }) =>
    isSelected
      ? [{ key: "enter", label: "submit" }, { key: "esc", label: "back" }]
      : [{ key: "↑↓←→", label: "navigate" }, { key: "enter", label: "select" }],
})

For static hints, pass an array:

useInteractive({
  id,
  shortcuts: [{ key: "enter", label: "submit" }],
})

Consume the active shortcuts from a StatusBar with useFocusedShortcuts.

Display wrappers share a focusId

A component that only needs to observe focus state for example, a bordered wrapper around an already-interactive child should call useInteractive({ id }) with no shortcuts option and without calling onKey. Share the same id with the inner interactive component.

function BorderedWrapper({ id, children }: { id: string; children: ReactNode }) {
  const { isFocused, isSelected, isAnySelected, focusRef } = useInteractive({ id })
  const { borderColor, borderStyle } = useFocusBorderStyle({
    isFocused,
    isSelected,
    isAnySelected,
  })
  return (
    <box ref={focusRef} border borderStyle={borderStyle} borderColor={borderColor}>
      {children /* inner component uses useInteractive({ id }) with the same id */}
    </box>
  )
}

The outer call registers for state observation. The inner call owns the keyboard dispatch and shortcut registration. The internal useShortcuts call short-circuits on an empty array, so the wrapper does not stomp the inner component's registration.

This pattern is load-bearing it's the only way to compose a focus-responsive border around a component that already manages its own keyboard routing (like TextInput wrapping a native <input>). See the display wrapper rule on the Focus page.

Styled variant

If your component renders a <box border> that should show the 4-state focus affordance automatically, @gridland/ui ships useInteractiveStyled as a one-hook ergonomic:

import { useInteractiveStyled } from "@/hooks/use-interactive-styled"

function FocusCard({ id, children }: { id: string; children: ReactNode }) {
  const { focusRef, borderColor, borderStyle } = useInteractiveStyled({ id })
  return (
    <box ref={focusRef} border borderColor={borderColor} borderStyle={borderStyle}>
      {children}
    </box>
  )
}

It returns everything useInteractive does plus borderColor and borderStyle derived from useFocusBorderStyle. Because it depends on the theme it ships as a shadcn registry hook (bunx shadcn@latest add @gridland/use-interactive-styled) rather than through @gridland/utils.

When to use which: reach for the pure useInteractive from @gridland/utils whenever your component doesn't render a focus-responsive border (e.g. TextInput, PromptInput, anything wrapping a native <input>). Reach for useInteractiveStyled when you render a <box border> that should show the four-state affordance automatically.

  • Focus full conceptual model: FocusProvider, FocusScope, navigation, and the 4-state border affordance
  • useKeyboard raw keyboard primitive, used under the hood by onKey
  • useShortcuts the lower-level shortcut registration hook that useInteractive({ shortcuts }) calls internally