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 }): voidSubscribes 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>;
}| Name | Type | Default | Description |
|---|---|---|---|
options.isActive | boolean | true | When 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.
| Field | Description |
|---|---|
upArrow downArrow leftArrow rightArrow | Arrow keys. |
pageUp pageDown home end | Navigation keys. |
return escape tab backspace delete | Editing and control keys. |
ctrl shift meta | Modifier flags. |
super hyper capsLock numLock | Accepted, but always false over SSH. |
A few rules worth knowing:
- For a named key,
inputis''and the matchingkeyfield istrue. - Ctrl plus a letter gives
inputas that letter withkey.ctrlset totrue. - Typing an uppercase letter gives the letter in
inputwithkey.shiftset totrue.
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>
);
}| Name | Type | Description |
|---|---|---|
options.autoFocus | boolean | Take focus on mount if nothing else is focused. |
options.isActive | boolean | When false, the component is skipped in focus order. |
options.id | string | A 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(): StdinHandleReturns 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 }): voidReceives 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>;
}| Name | Type | Default | Description |
|---|---|---|---|
options.interval | number | 100 | Milliseconds between frames. |
options.isActive | boolean | true | When 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(): booleanAlways returns false. There is no screen reader over SSH. It exists for Ink compatibility.