mrkeyoor.com_
Sat 08 Aug 20:59 UTC
npmCLI & Toolingupdated 08 Aug 2026

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.

Verdict

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.

API stability3/5The React component model and core Text, Box, render, useInput, and useApp concepts are well established, but major upgrades carry real platform requirements. Version 7.1.1 requires Node 22 and React 19.2 peers, and the current README includes newer lifecycle, paste, window-size, cursor, animation, and non-interactive behavior that applications must test across terminal environments.
Docs5/5The repository README is an extensive API manual with installation, lifecycle rules, every component and hook, render options, testing, CI, screen readers, recipes, and examples. It calls out subtle facts such as Static only rendering newly appended items and non-interactive output retaining only the final dynamic frame. The main caveat is its notice that the README documents the upcoming version.
Maintenance5/5npm shows 7.1.1 published in July 2026, GitHub shows a push in August 2026, and the repository is not archived. The project tracks current React, React Reconciler, Yoga, Node, terminal protocols, and accessibility behavior. GitHub reports thirty-four open items including issues and pull requests, a modest queue for a renderer used across many large CLIs.
Ecosystem5/5Ink recorded 5,695,648 downloads in the latest measured week and has 39,592 GitHub stars. Its README names production users including Claude Code, Gemini CLI, GitHub Copilot CLI, Wrangler, Prisma, and Shopify CLI, while a large companion ecosystem supplies inputs, spinners, links, tables, gradients, testing helpers, and routing recipes.

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
Skip it if

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

PackageRegistryPick it when
blessednpmYou want a widget-oriented terminal UI without React and can work with an older callback-style ecosystem
terminal-kitnpmYou need low-level terminal control, widgets, input, and drawing APIs without React reconciliation
enquirernpmYour CLI is mainly a sequence of prompts rather than a continuously rendered application
@clack/promptsnpmYou want polished, compact prompts and progress indicators for a conventional command workflow