A terminal window showing a colorful command-line interface, with a gray-blue cat watching nearby

Ink: When Your Terminal Learns to Speak React

The Gray Cat
The Gray Cat
0 views

Building a command-line tool used to mean wrestling with raw ANSI escape codes, juggling cursor positions by hand, and praying your output didn't smear across the screen on the next redraw. Ink rejects all of that. It is a full React renderer whose output device is your terminal instead of the browser DOM. You write the same components and hooks you'd write for a web app, and Ink translates them into styled, laid-out, interactive console output.

The payoff is enormous: flexbox layout in your terminal, state-driven re-renders, composable components, and a hooks API for keyboard input. It is the engine behind some of the most-used CLIs in the world today, including Claude Code, GitHub Copilot CLI, the Shopify CLI, Prisma, and Gatsby. If you already think in React, Ink lets you ship a beautiful terminal experience without learning a bespoke widget toolkit.

Why Ink Feels Like Home

Ink leans on Yoga, the same Flexbox layout engine that powers React Native, to compute where everything goes. Every element behaves like a flex container by default, so the flexDirection, padding, gap, and justifyContent properties you know from CSS work exactly as expected — except the "pixels" are terminal cells.

A few things make it stand out:

  • Components and hooks, not escape codes. You compose <Box> and <Text> and let the reconciler diff frames for you.
  • Flexbox layout via Yoga. Columns, rows, gaps, borders, and alignment all map to familiar CSS-like props.
  • Keyboard interactivity. useInput and useFocus turn arrow keys, Tab navigation, and shortcuts into ordinary event handlers.
  • Concurrent rendering. React Suspense and async data work, so loading states are first-class.
  • Accessibility. ARIA attributes flow through to screen readers — a genuine rarity for terminal apps.
  • Testability. The companion ink-testing-library lets you assert on rendered frames just like Testing Library does on the web.

Getting Ink Into Your Project

Ink ships as a single package and expects React alongside it. As of version 7 it targets modern runtimes, so make sure you are on Node.js 22 or newer and React 19.2 or newer.

npm install ink react
yarn add ink react

TypeScript users should also grab @types/react at version 19.2 or above. That's the entire setup — there's no build plugin to configure and no special bundler step.

Your First Frame

The heart of Ink is the render function. Hand it a component tree and it mounts it to standard output, re-rendering automatically whenever state changes — exactly like createRoot().render on the web.

import React, { useState, useEffect } from 'react';
import { render, Text } from 'ink';

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(previous => previous + 1);
    }, 100);

    return () => clearInterval(timer);
  }, []);

  return <Text color="green">{count} ticks elapsed</Text>;
};

render(<Counter />);

The <Text> component is your styling primitive. It accepts color, backgroundColor, bold, italic, underline, dimColor, and wrap props, among others. Because the whole tree is reactive, you never manually clear or rewrite a line — you change state and Ink computes the minimal update.

Composing Layouts With Box

<Box> is Ink's flex container, the terminal equivalent of a <div>. You arrange children with the same vocabulary you'd use in CSS, and you can wrap regions in borders to give your output real structure.

import React from 'react';
import { render, Box, Text } from 'ink';

const Dashboard = () => (
  <Box flexDirection="column" padding={1} borderStyle="round" borderColor="cyan">
    <Text bold>Build Summary</Text>

    <Box marginTop={1} justifyContent="space-between">
      <Text>Passed</Text>
      <Text color="green">42</Text>
    </Box>

    <Box justifyContent="space-between">
      <Text>Failed</Text>
      <Text color="red">3</Text>
    </Box>
  </Box>
);

render(<Dashboard />);

Here flexDirection="column" stacks the rows vertically, justifyContent="space-between" pushes the label and value to opposite edges, and borderStyle="round" draws a rounded frame. Swap in single, double, or bold for a different look. The layout reflows automatically if the content or terminal width changes.

Reacting to the Keyboard

What turns a static report into an interactive app is input handling, and Ink exposes that through the useInput hook. It fires a handler on every keypress, giving you the typed character and a key object describing modifier and special keys.

import React, { useState } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';

const Menu = () => {
  const items = ['Deploy', 'Rollback', 'View Logs'];
  const [selected, setSelected] = useState(0);
  const { exit } = useApp();

  useInput((input, key) => {
    if (key.upArrow) setSelected(prev => Math.max(0, prev - 1));
    if (key.downArrow) setSelected(prev => Math.min(items.length - 1, prev + 1));
    if (key.return) exit();
    if (input === 'q') exit();
  });

  return (
    <Box flexDirection="column">
      {items.map((item, index) => (
        <Text key={item} color={index === selected ? 'green' : undefined}>
          {index === selected ? '> ' : '  '}
          {item}
        </Text>
      ))}
    </Box>
  );
};

render(<Menu />);

The useApp hook hands you an exit function for a clean unmount. Together these two hooks are enough to build menus, prompts, and wizards. The key object also exposes leftArrow, rightArrow, escape, tab, backspace, ctrl, shift, and more, so any keyboard scheme you can imagine is a handful of conditionals away.

Going Further

Keeping Logs Out of the Render Loop

A naive terminal app re-renders its entire output on every frame, which is fine for a small dashboard but disastrous for a long-running task that streams hundreds of log lines. Ink solves this with the <Static> component. Anything inside it is printed once, permanently, above the live UI — and never re-rendered again. This is the pattern test runners and installers use to scroll completed work into history while keeping a live spinner at the bottom.

import React, { useState, useEffect } from 'react';
import { render, Static, Box, Text } from 'ink';

const Installer = () => {
  const [completed, setCompleted] = useState<string[]>([]);
  const queue = ['react', 'ink', 'yoga-layout', 'chalk'];

  useEffect(() => {
    let index = 0;
    const timer = setInterval(() => {
      if (index >= queue.length) return clearInterval(timer);
      setCompleted(prev => [...prev, queue[index++]]);
    }, 400);

    return () => clearInterval(timer);
  }, []);

  return (
    <>
      <Static items={completed}>
        {pkg => (
          <Text key={pkg} color="green">
            ✓ installed {pkg}
          </Text>
        )}
      </Static>

      <Box marginTop={1}>
        <Text dimColor>
          {completed.length} / {queue.length} packages installed
        </Text>
      </Box>
    </>
  );
};

render(<Installer />);

Because the completed lines live in <Static>, Ink writes each one exactly once and only redraws the small live footer. The result is smooth, flicker-free output even when thousands of lines stream by.

Focus Management and Multi-Field Forms

For anything resembling a form, manually tracking which field is "active" gets old fast. Ink ships a focus system built around useFocus and useFocusManager. Mark components as focusable, and Ink wires up Tab and Shift+Tab navigation between them automatically.

import React from 'react';
import { render, Box, Text, useFocus, useFocusManager, useInput } from 'ink';

const Field = ({ label }: { label: string }) => {
  const { isFocused } = useFocus();

  return (
    <Text color={isFocused ? 'cyan' : undefined}>
      {isFocused ? '› ' : '  '}
      {label}
    </Text>
  );
};

const Form = () => {
  const { focusNext } = useFocusManager();

  useInput((_input, key) => {
    if (key.downArrow) focusNext();
  });

  return (
    <Box flexDirection="column">
      <Field label="Name" />
      <Field label="Email" />
      <Field label="Password" />
    </Box>
  );
};

render(<Form />);

Each Field learns whether it's focused from useFocus, while useFocusManager gives you imperative control with focusNext, focusPrevious, and focus(id). This is how you build accessible, navigable interfaces without threading "selected index" state through every component.

Tapping Into the Modern Toolbox

Version 7 brought a batch of hooks that make richer apps far easier. useWindowSize returns the live terminal dimensions and updates on resize, so you can build responsive layouts. usePaste handles bracketed-paste mode for multi-line clipboard input. useAnimation drives frame-based animations, and useBoxMetrics measures a box at runtime.

import React from 'react';
import { render, Box, Text, useWindowSize } from 'ink';

const Responsive = () => {
  const { width, height } = useWindowSize();
  const layout = width < 80 ? 'column' : 'row';

  return (
    <Box flexDirection={layout} gap={2}>
      <Text>Terminal is {width}×{height}</Text>
      <Text dimColor>Layout: {layout}</Text>
    </Box>
  );
};

render(<Responsive />);

Also new in version 7 is the alternateScreen render option, which moves your app into the terminal's alternate buffer the way vim and less do, leaving the user's scrollback untouched when the app exits. The freshly added suspendTerminal() from 7.1 lets you hand control to a child process — an external editor, say — and reclaim the screen afterward.

Picking Ink Over the Alternatives

The long-standing alternative is blessed, the original rich terminal widget library, along with its maintained fork neo-blessed and the React adapter react-blessed. Those tools think in imperative widgets — boxes, forms, and buttons you mutate directly — and blessed itself is now largely unmaintained. terminal-kit offers a feature-packed imperative toolkit but is likewise not component-based.

Ink's bet is different: declarative composition, real React state and hooks, flexbox layout, and a testing story that mirrors the web. The trade-off is that it ships a smaller set of built-in widgets, expecting you to compose your own from <Box> and <Text> (or reach for community packages like ink-text-input, ink-select-input, ink-spinner, and ink-table). For a React developer on a modern Node runtime, that's a trade worth making — and with roughly four million weekly downloads, the ecosystem clearly agrees.

Wrapping Up

Ink collapses the distance between web and terminal development to almost nothing. The components, the hooks, the state model, even the layout engine — they're all the React you already know, just pointed at a different output device. Start with render, <Box>, and <Text> to lay things out; reach for useInput and useFocus when you need interactivity; and lean on <Static> to keep long-running output fast. From there you have everything you need to build a CLI that feels as polished as the tools shipping it to millions of developers every day.