Ink: React as a CLI Rendering Engine

September 1, 2025

|repo-review

by Florian Narr

Ink: React as a CLI Rendering Engine

Ink is a React renderer for the terminal. You write React components, they render to stdout. Full hooks support, Flexbox layout via Yoga, and a custom reconciler that speaks to a virtual DOM instead of the browser.

Why I starred it

CLIs are almost always stateless strings of output. The moment you need anything interactive — a progress bar that updates, a form that accepts input, a menu you can navigate — you're writing raw escape codes or reaching for a library that's just string manipulation with extra steps.

Ink takes a different position: if you already know React, you already know how to build interactive UIs. The constraint changes from "learn a new model" to "use the model you already have, but in the terminal."

37,000 stars on GitHub. The concept isn't new (v1 was 2017), but the codebase has been actively evolving — version 7.0 shipped April 2026, with React 19 support and a fresh round of dependency updates.

How it works

The core is a custom React reconciler in src/reconciler.ts. It's built on react-reconciler, the same low-level package that powers React DOM and React Native. Instead of creating DOM nodes or native views, Ink's reconciler creates a virtual tree of DOMElement nodes — ink-root, ink-box, ink-text, and ink-virtual-text.

// src/dom.ts
export const createNode = (nodeName: ElementNames): DOMElement => {
  const node: DOMElement = {
    nodeName,
    style: {},
    attributes: {},
    childNodes: [],
    parentNode: undefined,
    yogaNode: nodeName === 'ink-virtual-text' ? undefined : Yoga.Node.create(),
    internal_accessibility: {},
  };

  if (nodeName === 'ink-text') {
    node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node));
  }

  return node;
};

Each node gets a Yoga layout node attached to it. When React commits changes, Ink runs the Yoga layout engine to compute positions and dimensions — exactly how React Native handles layout on mobile. The result is actual Flexbox in your terminal.

After layout, src/renderer.ts calls renderNodeToOutput, which walks the tree and writes characters into an Output buffer. That buffer is a 2D character grid at the computed dimensions. Then src/log-update.ts handles the actual terminal writes.

The terminal rendering has two modes. Standard mode re-renders the full frame using ansi-escapes.eraseLines. Incremental mode — the more interesting one — does line-by-line diffing:

// src/log-update.ts
for (let i = 0; i < visibleCount; i++) {
  // We do not write lines if the contents are the same.
  // This prevents flickering during renders.
  if (nextLines[i] === previousLines[i]) {
    if (!isLastLine || hasTrailingNewline) {
      buffer.push(ansiEscapes.cursorNextLine);
    }
    continue;
  }

  buffer.push(
    ansiEscapes.cursorTo(0) +
      nextLines[i] +
      ansiEscapes.eraseEndLine + '\n',
  );
}

Only changed lines get redrawn. For anything with stable sections — a progress bar at the bottom while logs scroll above — this matters.

There's also screen reader support. renderNodeToScreenReaderOutput in src/render-node-to-output.ts has a full parallel render path that produces semantic text output, using an internal_accessibility property on each node with ARIA-like roles (button, progressbar, listitem, etc.) and state flags (checked, disabled, expanded). I didn't expect that level of attention in a CLI library.

Input handling lives in src/hooks/use-input.ts. It uses React 19's useEffectEvent to avoid re-subscribing on every render — a small thing that shows the codebase is staying current. Keyboard parsing in src/parse-keypress.ts handles everything from arrow keys to Kitty keyboard protocol, which enables modifier key disambiguation that normal VT sequences can't do.

Using it

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

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

  useEffect(() => {
    const t = setInterval(() => setCount(c => c + 1), 100);
    return () => clearInterval(t);
  }, []);

  return (
    <Box borderStyle="round" padding={1}>
      <Text color="green">Count: {count}</Text>
    </Box>
  );
};

render(<Counter />);

That renders a live-updating counter inside a rounded border box. The Flexbox props mirror CSS closely: flexDirection, justifyContent, alignItems, gap, padding, margin. The Text component takes color, bold, italic, strikethrough, dimColor.

For input:

import { useInput } from 'ink';

useInput((input, key) => {
  if (key.upArrow) move('up');
  if (key.escape) exit();
  if (input === 'q') exit();
});

useInput gives you both the raw character and a Key object with named flags for every special key.

Testing is handled via ink-testing-library, which renders to an in-memory string without attaching to a real TTY.

Rough edges

The dependency list is long: Yoga, react-reconciler, scheduler, ansi-escapes, wrap-ansi, slice-ansi, string-width, widest-line, ansi-styles, chalk, and more. If you're building a small CLI script, that's a lot of weight. The bundle isn't designed for minimal footprint.

Yoga's WASM binary adds startup latency — noticeable on first run if you're used to fast CLI tools. For a tool that runs once and exits, that overhead adds up.

The docs are decent for the public API but thin on internals. There's no architecture document, and the reconciler integration isn't documented outside the source. If you need to do something non-standard — a custom output transformer, a raw-mode fallback — you're reading the code.

There's also no streaming output support. Everything is rendered as a full frame. For a tool dumping a lot of sequential log lines, you'll want to think carefully about which parts are static vs. interactive.

Bottom line

Ink is the right choice if you're building a CLI with real interactive state: menus, forms, progress tracking, anything that needs to update in place. If you already write React, the mental model is zero cost — you're just pointing the renderer at a different target.

vadimdemedes/ink on GitHub
vadimdemedes/ink