Ink — React for the Terminal
Ink renders React components to terminal output using Yoga (flexbox) for layout. <Box> replaces <div>, <Text> replaces <span>. The terminal is the viewport; rows are height, columns are width. Every element is display: flex — there is no block layout.
Used in production by Claude Code, Gemini CLI, Cloudflare Wrangler, Shopify CLI, Prisma, Gatsby, Yarn 2, and 40+ other major CLIs.
Project Setup
Ink 6.x requires ESM and pairs with React 19.x.
// package.json
{ "type": "module" }
// tsconfig.json (key fields)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx", // automatic runtime — never import React
"strict": true
}
}
NodeNext resolution requires .js extensions on all local imports, even for .tsx source files:
import { App } from "./app.js"; // correct
import { App } from "./app"; // breaks at runtime
The Static/Dynamic Split
This is the most important architectural pattern in production Ink apps. Ink performs full-tree traversal and complete screen redraws on every state change. Without mitigation, a CLI displaying hundreds of log lines redraws all of them on every tick.
Split the UI into two zones:
<>
<Static items={completedTasks}>
{task => <Text key={task.id}>{task.status} {task.name}</Text>}
</Static>
<Box>
<Spinner /> <Text>Running {currentTask}...</Text>
</Box>
</>
<Static>— Content rendered once and permanently frozen above the dynamic area. Never re-renders. Use for: completed tasks, build output, test results, log lines, streamed messages.- Dynamic area — Everything below
<Static>. Re-renders freely for spinners, progress bars, current status.
Items passed to <Static> must be referentially stable — it compares by reference to detect new entries. Append new items; never replace the array.
// Correct: functional updater preserves reference stability
setLogs(prev => [...prev, newEntry]);
// Wrong: stale closure captures old array
setLogs([...logs, newEntry]);
This stale-closure pattern is the most common bug in streaming Ink apps.
Render Lifecycle
import { render } from "ink";
const instance = render(<App />, {
exitOnCtrlC: true, // auto-exit on Ctrl+C (default: true)
patchConsole: true, // intercept console.log (default: true)
alternateScreen: false, // fullscreen mode
incrementalRendering: false, // only update changed lines (v6+)
});
await instance.waitUntilExit();
// Safe to log, close DB connections, clean up temp files
Key options:
exitOnCtrlC: false— Use when you need custom cleanup before exit. Handle Ctrl+C yourself:
const { exit } = useApp();
useInput((input, key) => {
if (key.ctrl && input === "c") {
cleanup();
exit();
}
});
incrementalRendering: true— Only redraws changed lines instead of the full screen. Significantly reduces flickering, especially in tmux.waitUntilExit()— Returns a Promise that resolves after unmount completes. Always await this before post-render work (logging, process.exit, etc.) — otherwise you race Ink's cleanup.alternateScreen: true— Enters a separate terminal buffer (like vim). On exit, restores the original screen contents. Good for fullscreen apps.
Layout with Box
<Box> is the only layout primitive. Default direction is row.
// Vertical stack with spacing
<Box flexDirection="column" gap={1}>
<Text>First</Text>
<Text>Second</Text>
</Box>
// Centered horizontal row that wraps on narrow terminals
<Box flexDirection="row" flexWrap="wrap" gap={1} justifyContent="center">
{items.map(item => <Card key={item.id} />)}
</Box>
Padding and Dimensions
<Box paddingX={2} paddingY={1}> {/* 2 cols left+right, 1 row top+bottom */}
<Box height={17} width={28}> {/* fixed rows × columns */}
Set explicit dimensions when layout stability matters — particularly for elements that transition between states (hidden → visible, loading → loaded).
Borders
<Box borderStyle="round" borderColor="cyan" paddingX={1}>
Styles: "single", "double", "round", "bold", "singleDouble", "doubleSingle", "classic", or a custom character object. Border is drawn outside padding, inside dimensions — a height={5} box with a border has 3 usable interior rows.
Spacers
Use <Box flexGrow={1} /> (or the <Spacer /> component) as an expanding spacer:
<Box flexDirection="column" height={17}>
<Text>Top</Text>
<Box flexGrow={1} />
<Text>Centered</Text>
<Box flexGrow={1} />
</Box>
Text
All visible characters must be inside a <Text>. Raw strings in <Box> will error.
<Text bold color="magenta">Header</Text>
<Text dimColor>Secondary info</Text>
<Text italic wrap="wrap">{longPassage}</Text>
Colors accept named colors, hex, or rgb. dimColor reduces brightness without changing the hue.
Nest <Text> for inline style changes — the only component that supports inline nesting:
<Text dimColor>
Status: <Text color="green">ready</Text>
</Text>
Do not nest <Box> inside <Text>.
Blank Lines and Spacing
Prefer gap on the parent <Box> for structural spacing between siblings. Use <Newline /> only for intentional whitespace within a text flow. Avoid empty Text nodes or {"\n"}.
Hooks
useApp — Exit Control
const { exit } = useApp();
useEffect(() => {
if (done) {
const id = setTimeout(() => exit(), 100); // let final render complete
return () => clearTimeout(id);
}
}, [done, exit]);
Pass an Error to exit(error) to make waitUntilExit() reject — allows the caller to handle failures.
useInput — Keyboard Handling
useInput(
(input, key) => {
if (key.return) submit();
if (key.escape) cancel();
if (input === "q") quit();
},
{ isActive: isFocused && !loading }
);
Always use isActive when the handler should only run conditionally. Without it, every useInput in the tree fires on every keypress — this is the source of most input-related bugs in multi-component UIs.
Guard for TTY in non-interactive contexts:
{ isActive: process.stdin.isTTY === true && !done }
useWindowSize — Responsive Layouts
const { width, height } = useWindowSize();
Re-renders on terminal resize. Use for responsive layouts instead of reading stdout.columns directly.
useFocus — Tab Navigation
const { isFocused } = useFocus({ autoFocus: true });
Tab cycles focus between focusable components. Combine with useInput's isActive: isFocused for scoped input handling. Use useFocusManager() for programmatic focus control.
Non-TTY and CI Environments
Ink detects CI environments automatically and renders only the final frame instead of continuously updating. Components should also check:
const { isRawModeSupported } = useStdin();
if (!isRawModeSupported) {
return <Text>Using defaults (non-interactive mode)</Text>;
}
return <InteractivePrompt />;
For CLIs that need both interactive and machine-readable output:
if (process.argv.includes("--json")) {
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
} else {
render(<App data={data} />);
}
Performance
Ink redraws the entire dynamic area on every state change. Strategies to minimize flickering:
- Use
<Static>aggressively — move all immutable output out of the dynamic area - Enable
incrementalRendering: true— only updates changed lines (v6+) - Batch state updates — don't
setStateon every stream chunk; accumulate and flush - Memoize derived state —
useMemofor expensive computations,React.memofor pure components - Use refs for high-frequency state — animation ticks, frame counters, and similar rapid updates should live in
useRef, notuseState. Derive the visible state andsetStateonce per frame.
Error Handling
React error boundaries work in Ink. Ink also wraps apps in a built-in error boundary that renders a formatted error overview instead of raw stack traces.
For async errors:
const { exit } = useApp();
useEffect(() => {
fetchData().catch(error => exit(error));
}, []);
Errors passed to exit() cause waitUntilExit() to reject, allowing the caller to handle them.
Testing
Use ink-testing-library (inspired by react-testing-library):
import { render } from "ink-testing-library";
const { lastFrame, stdin } = render(<MyApp />);
expect(lastFrame()).toContain("Welcome");
stdin.write("hello");
stdin.write("\r"); // Enter
expect(lastFrame()).toContain("Done!");
For snapshot-style tests without a terminal:
import { renderToString } from "ink";
const output = renderToString(<MyComponent />, { columns: 80 });
Ecosystem
@inkjs/ui — Official Component Library
The @inkjs/ui package provides production-ready components with a theme system:
| Component | Purpose |
|---|---|
TextInput |
Single-line text with optional autocomplete |
PasswordInput |
Masked input |
ConfirmInput |
Y/n prompt |
Select |
Scrollable single-select list |
MultiSelect |
Multiple selections |
Spinner |
Animated loading indicator with label |
ProgressBar |
Percentage-based progress |
Badge |
Status indicator (success/error/warning/info) |
StatusMessage |
Formatted status messages |
Alert |
High-priority status messages |
Prefer @inkjs/ui components over community alternatives — they integrate with Ink's focus system and theme provider.
Other Useful Packages
| Package | Purpose |
|---|---|
ink-link |
Clickable terminal hyperlinks |
ink-gradient |
Gradient text coloring |
ink-big-text |
Large ASCII art text |
ink-table |
Tabular data display |
figures |
Cross-platform Unicode symbols |
chalk |
String-level color (for non-component contexts) |
Pastel — Framework for Larger CLIs
For CLIs with multiple commands, Pastel provides file-based routing (like Next.js for terminals). Files in commands/ become commands; Zod schemas define options with auto-generated help.
Common Mistakes
Not using
<Static>for log output — The #1 performance mistake. Every state change redraws the entire dynamic area. Completed/permanent output belongs in<Static>.Stale closures in streaming loops —
for await (const chunk of stream) { setItems([...items, chunk]) }captures staleitems. Use the functional updater:setItems(prev => [...prev, chunk]).Forgetting
isActiveonuseInput— Without it, alluseInputhooks fire simultaneously on every keypress.Not checking
isRawModeSupported— Crashes in CI/pipe environments when Ink triessetRawMode()on non-TTY stdin.Treating terminal as browser — Every element is
display: flex. There is no block layout. Expecting block-level flow leads to confused layouts.Text outside
<Text>—<Box>hello</Box>errors. All visible characters must be in<Text>.Logging after unmount without
waitUntilExit()— Output may appear before cursor restoration. Always awaitwaitUntilExit()before post-render work.Leaking intervals — Clear the interval when animation completes, not just on unmount. Otherwise the loop runs indefinitely after the work is done.
For the full component/hook prop reference, read references/api.md.