Build a CLI with React Ink: components in a terminal
Ink renders React to stdout, not the DOM. How to structure a multi-screen CLI, design the flag table, and stay usable when the output is a pipe.
React, rendered to stdout
Ink is a React renderer whose output target is the terminal rather than the DOM. The component model, hooks, and state are all the ones you already use; what changes is the host — <Text> and <Box> instead of <span> and <div>, and a flexbox layout engine drawing into a character grid.
That makes it worth reaching for when a CLI has become an app: multi-step flows, live progress, keyboard navigation, a dashboard that updates in place. It is the wrong tool for a command that transforms input and exits — that wants an argument parser and console.log, and nothing more.
The interesting design question Ink raises is not how to render. It is that a CLI has two audiences — a person at a terminal and a script consuming output — and only one of them can see your layout.
Setting up a TypeScript project
The package is ink; React is a peer. The one piece of packaging that makes it a command rather than a script is the bin field, which maps a shell name to an executable file.
{
"name": "taskpilot",
"type": "module",
"bin": { "taskpilot": "./dist/cli.js" },
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.tsx"
},
"dependencies": { "ink": "^6", "react": "^19", "meow": "^13" }
}
Two details bite people here. The built entry file needs a shebang — #!/usr/bin/env node as its literal first line — or the shell has no idea what to run it with. And "type": "module" has to agree with the TypeScript output, so module and moduleResolution both want NodeNext, with jsx: "react-jsx" so components compile without importing React by hand.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
Box, Text, and flexbox in a character grid
render() mounts a tree into the terminal and returns an instance with unmount, rerender, clear, and waitUntilExit.
#!/usr/bin/env node
import { render, Box, Text } from "ink"
function App() {
return (
<Box flexDirection="column" padding={1}>
<Text color="cyan" bold>
TaskPilot
</Text>
<Text>Build, review, and ship tasks from your terminal.</Text>
</Box>
)
}
render(<App />)
<Text> is the only element that may contain a string — text outside one throws, which is the first error most people hit. <Box> is layout: it maps to a flexbox node (Ink uses Yoga underneath), so flexDirection, gap, padding, borderStyle, and justifyContent behave roughly as they do in CSS, resolved against terminal columns instead of pixels.
Think in columns rather than characters. Padding a string by hand is what <Box> exists to replace, and hand-padded strings break the moment content is dynamic or the window is narrow.
The flag table is the CLI's contract
Before any of the UI, decide the flags. A flag table is to a CLI what a route tree is to a web app: the contract between the person outside the program and the program, and the thing that is painful to change once anyone has scripted against it.
Give each flag a name, a type, an alias, a default, and whether it is required or repeatable. That vocabulary is worth keeping independent of the parser you happen to use — the shape of your interface is a product decision, and which library spells it is not.
import meow from "meow"
export const cli = meow(
`
Usage
$ taskpilot [project]
Options
--view, -v Start on a view: home, tasks, settings
--json Print machine-readable output
`,
{
importMeta: import.meta,
flags: {
view: { type: "string", shortFlag: "v", default: "home" },
json: { type: "boolean", default: false },
},
}
)
Parse at the entry point, not inside components. The UI should receive resolved props — initialView, projectName — so that the same components can be driven by a test, a different parser, or no parser at all.
Two audiences: a person, and a pipe
Ink detects interactivity from stdout.isTTY and CI detection, and degrades sensibly on its own: in a non-interactive run it renders only the final frame on exit rather than continuously repainting, because most CI environments do not handle ANSI escapes. The interactive option overrides that detection when you need to force it either way.
Sensible degradation is not the same as a good non-interactive interface, though. A person wants a dashboard; a script wants one stable line it can parse. Branch before rendering:
const isInteractive = Boolean(process.stdout.isTTY) && !process.env.CI
if (cli.flags.json || !isInteractive) {
process.stdout.write(JSON.stringify({ project, status: "ready" }) + "\n")
process.exit(0)
}
render(<App initialView={cli.flags.view} projectName={project} />)
This is the structural reason to keep data loading out of components. Both paths need the same data; only one of them needs a layout. When fetching lives in a hook inside a screen, the JSON path has to reimplement it, and the two drift.
Two exit details worth knowing. exitOnCtrlC defaults to true, so Ctrl+C works without wiring — but it matters explicitly once you put stdin in raw mode, where the terminal stops handling it for you. And waitUntilExit() returns a promise resolving when the app unmounts, which is how a wrapping script waits for the UI to finish rather than racing it.
Keyboard input and a screen stack
useInput subscribes a component to keypresses; useApp exposes exit.
import { Box, Text, useApp, useInput } from "ink"
const items = ["Tasks", "Settings", "Help"]
export function Menu() {
const [index, setIndex] = useState(0)
const { exit } = useApp()
useInput((input, key) => {
if (key.upArrow) setIndex((value) => Math.max(0, value - 1))
if (key.downArrow) setIndex((value) => Math.min(items.length - 1, value + 1))
if (input === "q") exit()
})
return (
<Box flexDirection="column">
{items.map((item, itemIndex) => (
<Text key={item} color={itemIndex === index ? "green" : undefined}>
{itemIndex === index ? "› " : " "}
{item}
</Text>
))}
<Text dimColor>↑ ↓ to move · q to quit</Text>
</Box>
)
}
Multi-screen navigation wants a screen stack — an array where pushing moves forward and popping goes back — rather than a router. It is a dozen lines, it makes "back" behave the way people expect, and it keeps the current path inspectable:
type Screen = "home" | "tasks" | "settings"
const [stack, setStack] = useState<Screen[]>(["home"])
const screen = stack.at(-1) ?? "home"
const push = (next: Screen) => setStack((current) => [...current, next])
const back = () =>
setStack((current) => (current.length > 1 ? current.slice(0, -1) : current))
Keep the keyboard hints next to what they control. A terminal has no affordances — nothing looks clickable — so the only discoverability a CLI has is the text you choose to show.
Shared state across screens
Local state for one screen's concerns; a store once several screens read the same thing. Zustand suits Ink well, since there is no provider to mount and the store is just a hook.
import { create } from "zustand"
type TaskState = {
tasks: Task[]
selected: number
move: (delta: number) => void
toggle: () => void
}
export const useTaskStore = create<TaskState>()((set) => ({
tasks: [],
selected: 0,
move: (delta) =>
set((state) => ({
selected: Math.max(
0,
Math.min(state.tasks.length - 1, state.selected + delta)
),
})),
toggle: () =>
set((state) => ({
tasks: state.tasks.map((task, index) =>
index === state.selected ? { ...task, done: !task.done } : task
),
})),
}))
Select narrowly — useTaskStore((state) => state.tasks) rather than the whole store — for the same reason as in a browser: a component that subscribes to everything repaints on every change, and in a terminal that repaint is a visible flicker. The rest of how to structure a Zustand store applies unchanged; nothing about it is DOM-specific.
Testing without asserting on escape codes
ink-testing-library renders a component and exposes its frames, plus a stdin to write to. The strategy that keeps tests durable is the same one that makes the JSON path cheap: keep pure logic — parsing, selection, formatting — outside components, test it directly, and reserve component tests for output and keyboard behaviour.
Assert on the text a user would read, never on the exact frame. Escape sequences are an implementation detail of the renderer, and a test coupled to them fails on the next layout tweak.
Where Ink sits among the alternatives
Use a plain parser — meow, commander, yargs — when the best interface really is fast predictable text. Use Ink when the CLI has state a person watches change. Use Pastel when you want Ink plus a command framework: it adds file-based commands and subcommands, Zod-defined options and arguments, and generated help, with Commander underneath.
Nodlume's Terminal target generates the Ink shape described here — screens under source/screens/, a screen-stack router, a bin entry with a non-TTY fallback — from the same canvas the web targets use. A flag table sits beside the route tree as a first-class part of the plan, for the reason at the top of this post: it is the contract, and contracts are worth deciding before implementation. The workspace exports it as a working project.
Frequently asked questions
Is React Ink production ready?
Yes — it is the renderer behind a number of widely used CLIs. Treat it like any dependency in a shipped binary: pin versions, test the flows that matter, and exit cleanly. The real production risk is not Ink but the assumption that every user has an interactive terminal, so build the non-TTY path deliberately and make errors readable without colour.
What is the difference between React Ink and Pastel?
Ink is the renderer; Pastel is a framework built on it. Pastel adds file-based commands and subcommands, options and arguments defined with Zod, type safety, and generated help, using Commander underneath. Choose Ink for full control of structure and parsing, Pastel when you want conventions for a multi-command tool.
Can React Ink components be tested?
Yes, with ink-testing-library, which renders components in a test and lets you write to their stdin. Keep parsing and selection logic in plain functions so most tests never touch the renderer, and assert on visible text rather than escape sequences.
Does React Ink work when output is piped or run in CI?
It does not break — Ink detects a non-TTY stdout or a CI environment and renders only the final frame instead of repainting. But detection is not a design: a piped run should usually emit JSON or plain text rather than a rendered dashboard, so branch on stdout.isTTY before calling render and keep the data layer shared between both paths.
What are the alternatives to React Ink for building a CLI?
For a command that prints and exits, an argument parser alone is simpler and starts faster. For full-screen terminal applications outside React, there are lower-level TUI and prompt libraries. Ink earns its place specifically when you want React's component model and live rendering in the terminal.
Target-specific scaffolding
Continue this learning path
Permissions, manifests, capabilities, agent projects and MCP servers for browser extensions, desktop, mobile, and backend agents.
Design your application
Design Web, Desktop, Mobile, Terminal, or Extension projects with a target-aware canvas and exporter.
Open projects