Serious React TUI renderer for serious CLI apps. Pure TypeScript Yoga layout, diff-based rendering, ScrollBox, alt buffer, mouse events, draggable and resizable components.
Used by claude-corp. Forked from claude-code-kit, itself a fork of Ink.
| Package | Description |
|---|---|
@yokai-tui/renderer |
React reconciler, component library, event system, terminal I/O |
@yokai-tui/shared |
Pure-TypeScript Yoga layout engine, logging, env helpers |
React component tree
→ Reconciler (React 19 host config)
→ DOM + Yoga layout
→ Tree traversal + text wrapping
→ Screen buffer (cell grid)
→ Frame diff → ANSI patches
→ stdout
The renderer double-buffers frames, diffs cell-by-cell, and emits only the minimal ANSI patch sequence each tick. A spinner update or a line of streamed text touches O(changed cells), not O(rows × cols).
import { render, Box, Text, ScrollBox } from '@yokai-tui/renderer'
function App() {
return (
<Box flexDirection="column" height="100%">
<Box flexGrow={1}>
<ScrollBox stickyScroll>
<Text>streaming content…</Text>
</ScrollBox>
</Box>
<Box>
<Text>footer</Text>
</Box>
</Box>
)
}
render(<App />)<Box> — flex container. Accepts all Yoga layout props: flexDirection, flexGrow, flexShrink, flexBasis, alignItems, justifyContent, gap, margin, padding, width, height, position, top, left, right, bottom, overflow, zIndex (only honored on position: 'absolute').
<Text> — text node. Accepts color, backgroundColor, bold, italic, underline, dimColor, wrap.
<ScrollBox> — scrollable container with imperative scroll API, sticky scroll, viewport culling, and DECSTBM hardware scroll hints.
<AlternateScreen> — enters the terminal alternate buffer with optional mouse tracking on mount, exits cleanly on unmount.
<Link> — OSC 8 hyperlink.
<Draggable> — gesture-captured drag primitive. Raise-on-press, drag-time z boost, optional bounds clamp, onDragStart / onDrag / onDragEnd. dragData payload forwarded to drop targets.
<DropTarget> — receiver side of drag-and-drop. Optional accept(data) => boolean filter, hover lifecycle (onDragEnter / onDragOver / onDragLeave), onDrop on the topmost target containing the cursor at release.
<Resizable> — resize primitive with s, e, se handles. Hover-highlighted chrome, minSize / maxSize clamping, onResizeStart / onResize / onResizeEnd. Defaults overflow: 'hidden' to keep content from bleeding outside the box.
<FocusGroup> — adds arrow-key navigation between focusable descendants without interfering with Tab. direction="row" | "column" | "both", optional wrap, optional isActive. Tab still cycles globally; arrows are bounded to the group.
<FocusRing> — focusable Box with built-in focus-visible border indicator (default cyan border on focus). Pair with <FocusGroup> for keyboard-navigable lists / menus.
<Checkbox> — [x] / [ ] boolean toggle. Click / Enter / Space toggles. Defaults claimFocusOnClick={false} so toggling doesn't tear focus from a peer (e.g. live-bound <TextInput>).
<Radio> — (x) / ( ) mutually-exclusive selection bound to a value. Same focus model as <Checkbox>. Compose with <FocusGroup> for arrow-key navigation.
<TextInput> — editable text. Single-line or multiline. Caret via the real terminal cursor (IME / a11y correct). Smart bracketed paste, undo/redo, word nav, selection, password masking. Focus-color border by default (configurable via borderColorFocus). Configurable cursor shape / blink / color via cursorStyle / cursorBlink / cursorColor. See docs/components/text-input.md.
const ref = useRef<ScrollBoxRef>(null)
<ScrollBox ref={ref} stickyScroll>
{items}
</ScrollBox>
// Imperative scroll
ref.current.scrollTo(100)
ref.current.scrollBy(10)
ref.current.scrollToBottom()
ref.current.scrollToElement(elementRef, { offset: 2 })Mouse tracking is enabled inside <AlternateScreen mouseTracking>. Events are dispatched through the component tree with capture and bubble phases, matching browser event semantics.
<Box
onClick={(e) => console.log(e.col, e.row)}
onMouseDown={(e) => console.log(e.col, e.row)}
onMouseEnter={() => console.log('hover')}
onMouseLeave={() => console.log('leave')}
onKeyDown={(e) => console.log(e.key)}
>Inside onMouseDown, call event.captureGesture({ onMove, onUp }) to claim subsequent mouse-motion events and the eventual release for that one drag. Selection extension is suppressed for the duration; the captured handlers fire even when the cursor leaves the originally-pressed element's bounds.
<Box
onMouseDown={(e) => {
e.captureGesture({
onMove: (m) => console.log('drag at', m.col, m.row),
onUp: (u) => console.log('release at', u.col, u.row),
})
}}
/><Draggable>, <DropTarget>, and <Resizable> are all built on top of this primitive. Reach for them first; reach for raw captureGesture when none of the components fit your interaction.
Tab / Shift+Tab cycle through every tabIndex >= 0 element in the tree (built into the renderer — no setup needed). Arrow-key navigation is opt-in via <FocusGroup>, scoped to its descendants — Tab still cycles globally, arrows are bounded.
import { FocusGroup, FocusRing, Text, useFocusManager } from '@yokai-tui/renderer'
function Menu({ items }) {
return (
<FocusGroup direction="column" wrap>
{items.map((item) => (
<FocusRing key={item.id} paddingX={1}>
<Text>{item.label}</Text>
</FocusRing>
))}
</FocusGroup>
)
}<FocusRing> adds a focus-visible border (default cyan) to the focused item — a thin Box wrapper around useFocus that you can replace with custom chrome by inlining useFocus() directly.
For programmatic focus (modal pulling focus on open, status bar showing the focused element), use useFocusManager():
const { focused, focus, focusNext, focusPrevious, blur } = useFocusManager()For per-element tracking, useFocus():
const { ref, isFocused, focus } = useFocus({ autoFocus: true })
return <Box ref={ref} tabIndex={0} ...>...</Box>See pnpm demo:focus-nav for a live example with two <FocusGroup>s side-by-side, one column-direction and one row-direction.
pnpm demo:drag # three overlapping draggable rectangles
pnpm demo:constrained-drag # constrained-vs-free drag inside containers
pnpm demo:drag-and-drop # kanban: cards into columns
pnpm demo:resizable # two panels, three handles each
pnpm demo:focus-nav # Tab + arrow navigation between FocusGroups
pnpm demo:text-input # text input fields: single, multiline, passwordEach demo lives in examples/ and is a small .tsx file you can read top-to-bottom — they're meant to be the first place you look when wiring a new interaction.
pnpm add @yokai-tui/renderer react
# or
npm install @yokai-tui/renderer reactSee release notes for what's in each version.
| Hook | Description |
|---|---|
useInput(handler, options?) |
Raw keyboard input |
useApp() |
{ exit } |
useStdin() |
Stdin stream + isRawModeSupported |
useStdout() |
Stdout stream + write |
useTerminalViewport() |
[ref, entry], where entry.isVisible tracks whether the referenced element is currently in the terminal viewport |
useFocus(options?) |
{ ref, isFocused, focus } — per-element focus tracking + imperative focus |
useFocusManager() |
{ focused, focus, focusNext, focusPrevious, blur } — global focus actions, reactive to changes |
useInterval(fn, ms) |
Stable interval that cleans up on unmount |
pnpm install
pnpm build # shared → renderer
pnpm typecheck # both packages
pnpm lint # biome
pnpm test # vitestFull reference under docs/ — getting started, conceptual guides, per-component and per-hook reference, real-world patterns, migration from Ink, and an AGENTS.md for AI assistants writing code that uses yokai.
See CONTRIBUTING.md for the rules of the road — branching, granular commits, co-authorship, quality bar, and release workflow.
MIT
