voxal docs
SDK · CoreSDK · UI (React)

Hooks

Input, focus, app control, terminal size, animation, and measurement hooks for voxal UIs

All hooks are imported from @voxalsh/sdk/ui and match Ink's. They cover keyboard input, focus, app lifecycle, terminal size, animation, and layout measurement. Call them at the top level of a component, following the usual React rules of hooks.

import { useInput, useApp, useFocus, useWindowSize } from '@voxalsh/sdk/ui';

useApp

useApp(): { exit(error?): void }

Returns the app controls. Call exit() to end the app, which closes the connection when you are running under serve. Pass an error to surface it to waitUntilExit.

function App() {
  const { exit } = useApp();
  useInput((input) => {
    if (input === 'q') exit();
  });
  return <Text>press q to quit</Text>;
}

useInput

useInput(handler: (input: string, key: Key) => void, options?: { isActive?: boolean }): void

Subscribes to keyboard input. input is the typed character ('' for named keys), and key is the key object describing which special key fired. The handler re-subscribes on every render, so it always sees your latest closure (current state and props).

function Menu() {
  const [i, setI] = useState(0);
  useInput((input, key) => {
    if (key.upArrow) setI((n) => Math.max(0, n - 1));
    if (key.downArrow) setI((n) => n + 1);
    if (key.return) console.log('selected', i);
  });
  return <Text>row {i}</Text>;
}
NameTypeDefaultDescription
options.isActivebooleantrueWhen false, the handler is not subscribed. Use it to disable input for unfocused components.

While exitOnCtrlC is on (the default), Ctrl-C is not delivered to your handler. The app exits instead. Set exitOnCtrlC: false if you need to handle it yourself.

The key object

The second argument to your useInput handler.

FieldDescription
upArrow downArrow leftArrow rightArrowArrow keys.
pageUp pageDown home endNavigation keys.
return escape tab backspace deleteEditing and control keys.
ctrl shift metaModifier flags.
super hyper capsLock numLockAccepted, but always false over SSH.

A few rules worth knowing:

  • For a named key, input is '' and the matching key field is true.
  • Ctrl plus a letter gives input as that letter with key.ctrl set to true.
  • Typing an uppercase letter gives the letter in input with key.shift set to true.

useFocus

useFocus(options?: { autoFocus?: boolean; isActive?: boolean; id?: string }): { isFocused: boolean; focus(id): void }

Registers the component in the focus system and reports whether it currently has focus. By default, Tab moves focus to the next focusable, Shift+Tab to the previous, and Esc clears focus.

function Field({ label }) {
  const { isFocused } = useFocus();
  return (
    <Box borderStyle="round" borderColor={isFocused ? 'cyan' : 'gray'}>
      <Text>{label}</Text>
    </Box>
  );
}
NameTypeDescription
options.autoFocusbooleanTake focus on mount if nothing else is focused.
options.isActivebooleanWhen false, the component is skipped in focus order.
options.idstringA stable id so you can target this component with focus(id).

useFocusManager

useFocusManager(): { enableFocus(); disableFocus(); focus(id); focusNext(); focusPrevious(); activeId }

Drives focus imperatively: enable or disable the whole focus system, move to the next or previous focusable, jump to a specific id, or read the activeId.

useStdout

useStdout(): { write(data: string): void }

Returns a handle that writes raw output to the connection, bypassing the React tree. Use it for one-off escape sequences or raw bytes you do not want to model as components.

useStderr

useStderr(): { write(data: string): void }

Same shape as useStdout and writes to the same connection. There is no separate error stream over SSH.

useStdin

useStdin(): StdinHandle

Returns a stdin handle for Ink compatibility. setRawMode and setBracketedPasteMode are no-ops, because the connection is always in raw mode with bracketed paste on, and isRawModeSupported is always true. For input, prefer useInput.

useWindowSize

useWindowSize(): { columns: number; rows: number }

Returns the live terminal size and re-renders the component when the terminal is resized. Use it to lay out against the current viewport.

function Bar() {
  const { columns } = useWindowSize();
  return <Text>{'='.repeat(columns)}</Text>;
}

usePaste

usePaste(handler: (text: string) => void, options?: { isActive?: boolean }): void

Receives pasted text as a single string. While any paste listener is mounted, pasted text goes to it instead of to useInput, so you can handle a multi-line paste in one shot rather than character by character.

useCursor

useCursor(): { setCursorPosition(pos?: { x: number; y: number }): void }

Controls the visible terminal cursor. Call setCursorPosition({ x, y }) (relative to the UI's top-left) to show the cursor at that cell, or pass undefined to hide it. Useful for text inputs where you want a real blinking cursor.

useBoxMetrics

useBoxMetrics(ref): { width; height; left; top; hasMeasured }

Reads the measured geometry of a box. Pass a ref attached to a <Box>. hasMeasured is false until the first layout completes.

function Sized() {
  const ref = useRef(null);
  const { width, height, hasMeasured } = useBoxMetrics(ref);
  return (
    <Box ref={ref} borderStyle="round" padding={1}>
      <Text>{hasMeasured ? `${width}x${height}` : 'measuring...'}</Text>
    </Box>
  );
}

measureElement

measureElement(node): { width: number; height: number }

Measures a node imperatively. Layout runs after commit, so read measurements from an effect or an event handler, not during render.

useEffect(() => {
  const { width, height } = measureElement(ref.current);
}, []);

useAnimation

useAnimation(options?: { interval?: number; isActive?: boolean }): { frame: number; time: number; delta: number; reset(): void }

Drives frame-based animation. frame increments each tick, time is elapsed milliseconds, delta is the gap since the last frame, and reset() restarts the clock.

function Spinner() {
  const frames = ['|', '/', '-', '\\'];
  const { frame } = useAnimation({ interval: 80 });
  return <Text>{frames[frame % frames.length]}</Text>;
}
NameTypeDefaultDescription
options.intervalnumber100Milliseconds between frames.
options.isActivebooleantrueWhen false, the animation pauses.

All animations in one app share a single timer, but timers are a bounded resource. Keep an eye on the timer limits if you run many independent animations.

useIsScreenReaderEnabled

useIsScreenReaderEnabled(): boolean

Always returns false. There is no screen reader over SSH. It exists for Ink compatibility.

On this page