UI (React)
Build terminal UIs with React and flexbox, one to one with Ink, from @voxalsh/sdk/ui
@voxalsh/sdk/ui is a real React renderer for the terminal. You build your interface with components and flexbox layout, manage state with hooks, and the renderer paints it to the screen, diffing cell by cell so only what changed is written.
It is built on react-reconciler and Yoga (flexbox), and it is one to one with Ink: the same components, hooks, and key object. If you know Ink, you already know this. Import everything from @voxalsh/sdk/ui. React is a peer dependency (React 18), so you import it yourself. The CLI bundles both into your app, and npm create voxal-app scaffolds a UI starter for you.
A counter
serve(factory) is the normal entry point. It runs factory(conn) once per SSH connection and gives each connection its own React tree.
import { serve, Box, Text, useInput, useApp } from '@voxalsh/sdk/ui';
import { useState } from 'react';
function Counter() {
const [n, setN] = useState(0);
const { exit } = useApp();
useInput((input, key) => {
if (input === 'q') exit();
if (key.upArrow) setN((c) => c + 1);
if (key.downArrow) setN((c) => c - 1);
});
return (
<Box borderStyle="round" padding={1} flexDirection="column">
<Text>count: <Text bold color="green">{n}</Text></Text>
<Text dimColor>up and down to change, q to quit</Text>
</Box>
);
}
serve(() => <Counter />);serve wires keyboard input, resize, and close for you, and closes the connection when the app exits. See serve and render for the full entry point reference.
Core vs UI
The core SDK gives you conn.write and raw keystrokes: full byte level control, and no React in your bundle. The UI layer trades that for components, declarative layout, and state driven re-rendering.
Reach for the UI layer when your app has layout or state: dashboards, forms, chats, menus, games. Reach for core when you want byte level control or your app is a simple sequence of writes. Both run entirely in the sandbox and share the same host contract, so the choice does not affect how you deploy or run.
How it relates to Ink
The API mirrors Ink, with a few voxal specific differences:
- A voxal connection replaces
process.stdoutandprocess.stdin. The renderer paints to the SSH channel, not to a local terminal. serve()is voxal specific sugar. Plain Ink only hasrender(), which the UI layer also provides.setRawModeand bracketed paste are no-ops. The connection is always in raw mode with bracketed paste on.mouseonly works infullscreenmode.- Accessibility and screen reader props (
aria-*,useIsScreenReaderEnabled) are accepted but inert. There is no screen reader over SSH.
Per-connection isolation
One app isolate serves many connections, but each connection gets its own React tree from factory(conn). State held inside your components (useState, useReducer) is private to one connection.
Module-level variables, by contrast, are shared across every connection in the isolate. That is sometimes exactly what you want (a shared scoreboard, a chat room) and sometimes a bug. See Sharing state across connections for the pattern.
Tip
Renders commit synchronously and coalesce on a microtask, so a burst of setState calls in one tick paints once. You almost never need to think about it.