gridland
Interaction

Focus

Focus management, keyboard routing, and selection

Gridland provides a unified focus and input system that works identically in both terminal and web runtimes.

Examples

Focus with Multi-Select

Focus & Navigation
?

Spatial Navigation

Spatial Navigation
?

Usage

The primary API is useInteractive — one hook that composes focus registration, selection-scoped keyboard routing, and shortcut hints. It lives in @gridland/utils (the runtime npm package), so you can use it without touching the shadcn registry layer. Theme-aware focus borders are a separate concern: call useFocusBorderStyle alongside it, or use the useInteractiveStyled wrapper from @gridland/ui for the one-hook ergonomic.

app.tsx
import { GridlandProvider } from "@/components/ui/provider"
import { StatusBar } from "@/components/ui/status-bar"
import { useInteractive, useFocusedShortcuts } from "@gridland/utils"
import { useFocusBorderStyle } from "@/lib/theme"

// 1. GridlandProvider implicitly wraps children in <FocusProvider selectable>
function App() {
  return (
    <GridlandProvider theme={darkTheme}>
      <box flexDirection="row" gap={1}>
        <Cell focusId="inbox" label="Inbox" autoFocus />
        <Cell focusId="drafts" label="Drafts" />
        <Cell focusId="sent" label="Sent" />
      </box>
      <AppStatusBar />
    </GridlandProvider>
  )
}

// 2. Make components interactive with useInteractive
function Cell({ focusId, label, autoFocus }) {
  const interactive = useInteractive({
    id: focusId,
    autoFocus,
    shortcuts: ({ isSelected }) =>
      isSelected
        ? [{ key: "enter", label: "submit" }, { key: "esc", label: "back" }]
        : [{ key: "↑↓←→", label: "navigate" }, { key: "enter", label: "select" }],
  })

  // 3. Opt into the themed focus-border affordance
  const { borderColor, borderStyle } = useFocusBorderStyle({
    isFocused: interactive.isFocused,
    isSelected: interactive.isSelected,
    isAnySelected: interactive.isAnySelected,
  })

  // 4. Handle keys while the component is selected
  interactive.onKey((e) => {
    if (e.name === "return") console.log("submitted", label)
  })

  return (
    <box
      ref={interactive.focusRef}
      border
      borderStyle={borderStyle}
      borderColor={borderColor}
    >
      <text>{label}</text>
      <text>{interactive.isSelected ? "selected" : "not selected"}</text>
    </box>
  )
}

// 5. Display context-sensitive shortcuts from the currently focused component
function AppStatusBar() {
  const shortcuts = useFocusedShortcuts()
  return <StatusBar items={shortcuts} />
}

useInteractive reference

OptionTypeDefaultDescription
idstringauto-generatedStable focus id
autoFocusbooleanfalseFocus immediately on mount
disabledbooleanfalseRemove from tab cycle
selectablebooleantrueWhether Enter selects the component
tabIndexnumber0Tab order; -1 skips tab navigation
shortcutsShortcutEntry[] | ({ isFocused, isSelected }) => ShortcutEntry[][]Hints surfaced via useFocusedShortcuts while focused. The function form re-evaluates whenever state changes.

Returns: focusRef, focusId, isFocused, isSelected, isAnySelected, onKey(handler), focus(), blur(), select(), deselect().

Styled variant

If your component renders a focus-responsive border, useInteractiveStyled from @gridland/ui bundles the border styling back in for a single-call ergonomic:

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

function FocusCard({ id, children }) {
  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.

onKey uses a ref swap — call it every render with a fresh closure. The underlying listener is registered once per mount and replaces the stored handler on each call. The handler fires only while this component is selected (focused + Enter pressed).

Structural context

  • FocusProvider — the root wrapper (mounted implicitly by GridlandProvider). Pass disableFocusProvider to GridlandProvider to opt out of the implicit wrap.
  • FocusScope — constrain navigation to a subtree (useful for modals and menus).

How it works

FocusProvider enables the focus system. Pass selectable to enable two-layer focus: components are first focused (highlighted), then selected (entered for interaction) with Enter. Escape deselects.

useInteractive registers a focusable element with the system. It returns:

  • isFocused / isSelected / isAnySelected for styling based on focus state. isAnySelected is scope-aware: it stays true for global-scope components even when the selection is saved behind a FocusScope
  • focusId to pass to other hooks like useKeyboard and useShortcuts when you need them directly
  • focusRef to attach to the root element so arrow-key navigation can measure positions
  • focus() / blur() to imperatively move focus
  • select() / deselect() to imperatively enter or exit selection
  • onKey(handler) to register a keyboard handler that fires only while this component is selected

Navigation works two ways: Tab/Shift+Tab cycles in linear order, arrow keys navigate spatially to the nearest neighbor. If a component handles arrows internally (e.g., a SelectInput), call event.preventDefault() to prevent the FocusProvider from also navigating.

Display wrappers share a focusId

A component that only needs to observe focus state (e.g. 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. The outer call registers for state observation; the shortcut dispatch is a no-op on an empty array, so the wrapper does not stomp the inner component's shortcut registration.

Advanced: raw primitives

These remain exported from @gridland/utils as escape hatches for advanced cases (global keyboard listeners, custom shortcut reducers, status bar integration). Reach for useInteractive first — the primitives below exist for cases it cannot express.

useKeyboard handles keyboard input. Pass focusId to scope it to the focused component, or global: true to always listen.

OptionTypeDescription
focusIdstringOnly fire when this ID is focused
globalbooleanAlways fire regardless of focus
releasebooleanInclude key release events
selectedOnlybooleanOnly fire when the component is selected (requires focusId)

useShortcuts registers keyboard hints tied to a focusId. They update automatically as the user navigates. useFocusedShortcuts reads the active shortcuts for display in a StatusBar.

FocusScope

Constrain navigation to a group of components. When trap is enabled, Tab wraps around instead of leaving the scope. Useful for modals and menus.

Example
<FocusScope trap autoFocus restoreOnUnmount>
  <TextInput label="Name" />
  <TextInput label="Email" />
  <button onClick={onClose}>Close</button>
</FocusScope>
PropTypeDefaultDescription
trapbooleanfalsePrevent Tab from leaving the scope
selectablebooleanfalseEnable Enter/Esc selection within this scope
autoFocusbooleanfalseFocus first element on mount
autoSelectbooleanfalseAuto-select if only one focusable element exists on mount
restoreOnUnmountbooleantrueRestore previous focus on unmount