gridland
Guides

Focus & Navigation

Focus management, keyboard routing, selection, and pointer events

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

Focus

Examples

Spatial Navigation

Spatial Navigation
?

Focus with Multi-Select

Focus & Navigation
?

Usage

app.tsx
import { FocusProvider, useFocus, useKeyboard, useShortcuts, useFocusedShortcuts, FocusScope } from "@gridland/utils"
import { GridlandProvider, StatusBar } from "@gridland/ui"

// 1. Wrap your app with FocusProvider
function App() {
  return (
    <GridlandProvider theme={darkTheme} useKeyboard={useKeyboard}>
      <FocusProvider selectable>
        <box flexDirection="row" gap={1}>
          <Cell id="inbox" label="Inbox" autoFocus />
          <Cell id="drafts" label="Drafts" />
          <Cell id="sent" label="Sent" />
        </box>
        <AppStatusBar />
      </FocusProvider>
    </GridlandProvider>
  )
}

// 2. Make components focusable with useFocus
function Cell({ id, label, autoFocus }) {
  const { isFocused, isSelected, focusId, focusRef } = useFocus({ id, autoFocus })

  // 3. Handle keyboard input when focused
  useKeyboard((e) => {
    if (e.name === "return") console.log("submitted", label)
  }, { focusId, selectedOnly: true })

  // 4. Register shortcuts for the status bar
  useShortcuts(
    isSelected
      ? [{ key: "enter", label: "submit" }, { key: "esc", label: "back" }]
      : [{ key: "↑↓←→", label: "navigate" }, { key: "enter", label: "select" }],
    focusId,
  )

  return (
    <box ref={focusRef} border borderColor={isSelected ? "blue" : isFocused ? "green" : "gray"}>
      <text>{label}</text>
      <text>{isSelected ? "selected" : "not selected"}</text>
    </box>
  )
}

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

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.

useFocus makes a component focusable. It returns:

  • isFocused / isSelected / isAnySelected for styling based on focus state
  • focusId to pass to other hooks like useKeyboard and useShortcuts
  • focusRef to attach to the root element so arrow-key navigation can measure positions
OptionTypeDefaultDescription
idstringauto-generatedUnique identifier for this focusable
tabIndexnumber0Tab order. -1 to skip tab navigation
autoFocusbooleanfalseFocus immediately on mount
disabledbooleanfalseSkip in tab order when true

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.

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

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
autoFocusbooleanfalseFocus first element on mount
restoreOnUnmountbooleantrueRestore previous focus on unmount

Pointer Events

Pointer Events
?

Usage

app.tsx
import { useState } from "react"

const colors = ["#ef4444", "#3b82f6", "#22c55e", "#8b5cf6"]
const names = ["Red", "Blue", "Green", "Purple"]

function PointerDemo() {
  const [selected, setSelected] = useState<number | null>(null)
  const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null)

  return (
    <box flexDirection="column" padding={1}>
      {/* Click to select a color */}
      <box flexDirection="row" gap={1}>
        {colors.map((color, i) => (
          <box
            key={color}
            flexGrow={1}
            height={3}
            border
            borderColor={i === selected ? color : "#555"}
            onMouseDown={(e) => {
              setSelected(i)
              setMousePos({ x: e.x, y: e.y })
            }}
          >
            <text style={{ fg: color, bold: i === selected }}>{names[i]}</text>
          </box>
        ))}
      </box>

      {/* Hover detection */}
      <HoverBox />

      {/* Display click result */}
      <text>{selected !== null ? `Clicked ${names[selected]}` : "Click a color"}</text>
      <text style={{ dim: true }}>{mousePos ? `mouse: ${mousePos.x}, ${mousePos.y}` : ""}</text>
    </box>
  )
}

function HoverBox() {
  const [hovering, setHovering] = useState(false)

  return (
    <box
      border
      borderColor={hovering ? "#22c55e" : "#555"}
      onMouseOver={() => setHovering(true)}
      onMouseOut={() => setHovering(false)}
    >
      <text>{hovering ? "Mouse inside!" : "Hover me"}</text>
    </box>
  )
}

How it works

All intrinsic elements (<box>, <text>, etc.) support mouse event props. Each event receives an object with x, y coordinates relative to the element.

onClick is a synthetic event that fires when mouseDown and mouseUp occur on the same element.

EventDescription
onMouseDownMouse button pressed
onMouseUpMouse button released
onClickPress and release on the same element
onMouseOverCursor enters the element
onMouseOutCursor leaves the element
onMouseScrollScroll wheel over the element

Cursor Highlight

Cursor Highlight
?

Usage

app.tsx
<TUI cursorHighlight cursorHighlightColor="#8888ff" cursorHighlightOpacity={0.2}>
  <box border>
    <text>Move the mouse over cells to see the highlight</text>
  </box>
</TUI>

How it works

Cursor highlight adds a visual overlay on the cell under the mouse cursor. Enable it on the root TUI component.

PropTypeDefaultDescription
cursorHighlightbooleanfalseEnable cell highlighting
cursorHighlightColorstringgrayCSS color for highlight
cursorHighlightOpacitynumber0.15Opacity of highlight