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
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.
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
| Option | Type | Default | Description |
|---|---|---|---|
id | string | auto-generated | Stable focus id |
autoFocus | boolean | false | Focus immediately on mount |
disabled | boolean | false | Remove from tab cycle |
selectable | boolean | true | Whether Enter selects the component |
tabIndex | number | 0 | Tab order; -1 skips tab navigation |
shortcuts | ShortcutEntry[] | ({ 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 byGridlandProvider). PassdisableFocusProvidertoGridlandProviderto 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/isAnySelectedfor styling based on focus state.isAnySelectedis scope-aware: it stays true for global-scope components even when the selection is saved behind aFocusScopefocusIdto pass to other hooks likeuseKeyboardanduseShortcutswhen you need them directlyfocusRefto attach to the root element so arrow-key navigation can measure positionsfocus()/blur()to imperatively move focusselect()/deselect()to imperatively enter or exit selectiononKey(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.
| Option | Type | Description |
|---|---|---|
focusId | string | Only fire when this ID is focused |
global | boolean | Always fire regardless of focus |
release | boolean | Include key release events |
selectedOnly | boolean | Only 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.
<FocusScope trap autoFocus restoreOnUnmount>
<TextInput label="Name" />
<TextInput label="Email" />
<button onClick={onClose}>Close</button>
</FocusScope>| Prop | Type | Default | Description |
|---|---|---|---|
trap | boolean | false | Prevent Tab from leaving the scope |
selectable | boolean | false | Enable Enter/Esc selection within this scope |
autoFocus | boolean | false | Focus first element on mount |
autoSelect | boolean | false | Auto-select if only one focusable element exists on mount |
restoreOnUnmount | boolean | true | Restore previous focus on unmount |
Related
- Pointer Events mouse handlers, event shape, hit testing, and propagation.
- Cursor Highlight highlight the terminal cell under the mouse cursor.