ink
Ink is a React renderer for interactive terminal applications. You write components with JSX, state, effects, context, and hooks, while Ink turns the tree into ANSI terminal output and lays it out with Yoga's Flexbox engine. Built-in Text, Box, Static, Transform, and lifecycle hooks cover rendering and keyboard input; community packages add spinners, text inputs, tables, links, and selection controls. It is a UI framework for long-lived CLIs, not an argument parser or shell-command runner.
The strongest choice for a complex terminal app when React is already a team skill. For ordinary command output or a short prompt flow, the runtime floor, dependency graph, JSX build, and lifecycle complexity are needless weight.
Use it if
- You are building a genuinely interactive terminal interface with multiple views, live updates, keyboard handling, and reusable components
- Your team already understands React state, effects, context, reconciliation, and JSX and wants that model in a CLI
- Flexbox-style layout is easier for your interface than manually managing cursor positions and ANSI escape sequences
- You need terminal UI testing, rerendering, focus management, screen-reader support, or static log output alongside a changing view
- Your CLI asks one or two questions and exits: Enquirer or @clack/prompts has much less framework and terminal-lifecycle machinery
- You cannot require modern runtimes: Ink 7.1.1 declares Node 22 or newer and peer dependencies on React and @types/react 19.2 or newer
- You want a tiny dependency tree: the current package declares twenty runtime dependencies, including react-reconciler, Yoga layout, terminal measurement helpers, and ws
- Most output is piped, redirected, or run in CI: the README says non-interactive mode skips cursor control, resize handling, and other terminal features and writes only the final non-static frame at unmount
- You need pixel layout, browser CSS, or arbitrary nested text: Ink uses terminal cells and a Flexbox subset, and its README requires all text to live inside Text components
Setup reality
Ink 7.1.1 is ESM-oriented and requires Node 22 or newer. Install `ink` and `react` together, with React 19.2 or newer; TypeScript projects also need matching `@types/react`. The package lists `react-devtools-core` as a peer for the optional devtools path. JSX does not run in Node by itself, so use the project scaffold or configure a TypeScript/JSX runner and build step. The README's manual path uses Babel with the React preset, which means a second configuration file and a compiled CLI entry. Every string in the component tree must be wrapped by Text, and every Box behaves as a Flexbox container, so browser muscle memory only partly transfers. Interactive input needs a TTY with raw-mode support. Pipes, CI, and redirected stdout take the non-interactive path, where cursor manipulation and resize behavior are disabled; design a plain-output fallback and test it. An Ink app exits when nothing remains in Node's event loop, while `useInput`, timers, and pending work keep it alive. Effects therefore need cleanup, and completion should call `exit()` or unmount deliberately. Terminal width, Unicode cell width, ANSI styling, reflow, Ctrl+C, pasted text, screen readers, and alternate keyboard protocols all affect behavior across terminals. Ink supplies primitives, not polished prompts, so production apps usually add more packages for inputs, spinners, links, and selection lists. That is justified for a real terminal application and excessive for a three-question installer.
Patterns
Render styled terminal textrender-basic-app
import React from 'react';
import {render, Text} from 'ink';
const App = () => <Text color="green">Ready</Text>;
render(<App />);Save JSX in a file handled by your TypeScript or JSX build. Plain Node does not parse JSX without a transform.
Lay out columns with Boxbuild-flex-layout
import {Box, Text} from 'ink';
const Status = () => (
<Box borderStyle="round" paddingX={1} gap={2}>
<Box width={18}><Text bold>build</Text></Box>
<Box flexGrow={1}><Text color="yellow">running</Text></Box>
</Box>
);Ink uses terminal cells and Yoga Flexbox, not browser layout. Every text node must be inside Text.
Render a live counterupdate-live-state
import React, {useEffect, useState} from 'react';
import {Text} from 'ink';
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => setCount(value => value + 1), 1000);
return () => clearInterval(timer);
}, []);
return <Text>{count} seconds</Text>;
};The timer keeps the Node process alive. Clean it up on unmount or the command will not exit when expected.
Handle arrows and quit keyshandle-keyboard-input
import {Text, useApp, useInput} from 'ink';
const Controls = () => {
const {exit} = useApp();
useInput((input, key) => {
if (input === 'q' || key.escape) exit();
if (key.leftArrow) moveSelection(-1);
if (key.rightArrow) moveSelection(1);
});
return <Text dimColor>Arrows move, q quits</Text>;
};Interactive input requires raw-mode-capable stdin. Check useStdin().isRawModeSupported and provide a fallback for pipes and CI.
Keep pasted text as one eventhandle-pasted-text
import {Text, usePaste} from 'ink';
const PasteTarget = () => {
usePaste(text => {
importPayload(text);
});
return <Text>Paste JSON now</Text>;
};usePaste enables bracketed paste mode and receives the full string, including newlines. Escape sequences are preserved, so validate the content.
Wait for an application resultreturn-exit-result
import {render} from 'ink';
const instance = render(<Picker />);
const selected = await instance.waitUntilExit();
console.log('selected:', selected);
// Inside Picker: useApp().exit(selectedValue)exit(Error) rejects waitUntilExit, while exit(any other value) resolves it. This is cleaner than hidden module-level state.
Keep completed tasks above a live viewpreserve-completed-output
import {Box, Static, Text} from 'ink';
const Progress = ({done}) => (
<>
<Static items={done}>
{item => <Text key={item.id} color="green">✓ {item.name}</Text>}
</Static>
<Box><Text>Completed: {done.length}</Text></Box>
</>
);Static renders only newly appended items. Editing an item already emitted does not update its old terminal line.
React to terminal resizingadapt-terminal-width
import {Text, useWindowSize} from 'ink';
const SizeAware = () => {
const {columns, rows} = useWindowSize();
return <Text>{columns < 80 ? 'compact' : 'wide'} ({columns}x{rows})</Text>;
};Resize events are terminal-dependent and disabled in non-interactive mode. Narrower reflow can briefly leave ghost lines in some emulators.
Make terminal controls focusablemanage-component-focus
import {Text, useFocus} from 'ink';
const Field = ({id, label}) => {
const {isFocused} = useFocus({id, autoFocus: id === 'name'});
return <Text inverse={isFocused}>{isFocused ? '> ' : ' '}{label}</Text>;
};Focus order follows render order and Tab moves between active focusable components. The component still has to handle its own input.
Render without a live terminalrender-to-string
import {Box, Text, renderToString} from 'ink';
const output = renderToString(
<Box padding={1}><Text color="green">Report ready</Text></Box>,
{columns: 80},
);
await writeFile('report.txt', output);Terminal hooks return no-op defaults in renderToString. The output can also contain ANSI styling unless your tree avoids it or you strip it.
Replace root props imperativelyrerender-root
import {render} from 'ink';
const app = render(<Status phase="starting" />);
app.rerender(<Status phase="running" />);
await finishWork();
app.unmount();Prefer normal React state for component-owned updates. Manual unmount ends the UI but your own timers and handles still need cleanup.
Choose a plain fallback when stdout is redirectedsupport-noninteractive-output
import process from 'node:process';
import {render} from 'ink';
if (process.stdout.isTTY) {
render(<Dashboard />);
} else {
process.stdout.write(JSON.stringify(await collectStatus()) + '\n');
}Ink has a non-interactive mode, but an explicit machine-readable branch is easier for scripts than relying on the final rendered frame.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| blessed | npm | You want a widget-oriented terminal UI without React and can work with an older callback-style ecosystem |
| terminal-kit | npm | You need low-level terminal control, widgets, input, and drawing APIs without React reconciliation |
| enquirer | npm | Your CLI is mainly a sequence of prompts rather than a continuously rendered application |
| @clack/prompts | npm | You want polished, compact prompts and progress indicators for a conventional command workflow |