🖥️ Terminal UI (Ink)
It's React — but instead of DOM nodes, it writes ANSI escape codes to stdout
Claude Code's terminal UI is built with Ink — a React renderer for the terminal. Same useState, same useEffect, same component model — but instead of DOM nodes, the output is ANSI escape codes written to stdout.
- Custom reconciler translates React component tree into virtual terminal nodes
- Yoga layout engine provides CSS flexbox for character grids (Box = div, Text = span)
- Rendering pipeline: Components → Reconciler → Yoga Layout → Virtual DOM → ANSI → stdout
Terminal Rendering Pipeline
What you are seeing
The complete path from a React component to colored characters on your terminal. Each stage transforms the representation: JSX becomes virtual nodes, virtual nodes get laid out by Yoga, layout positions become ANSI escape sequences.
What to try
Trace how a simple <Box flexDirection="row"> becomes terminal output. Notice the parallel to browser rendering: React tree → layout → paint → pixels, except here it is React tree → Yoga → ANSI → characters.
# Browser React vs Terminal React
Browser: JSX → React DOM → CSS Layout → Pixels on screen
Terminal: JSX → Ink Reconciler → Yoga Layout → ANSI on stdout
# Component mapping
<div> → <Box> (container, flexbox)
<span> → <Text> (text content)
CSS pixels → char cells (80×24 grid)
onClick → onKeypress (keyboard events)
cursor:ptr → useFocus() (tab navigation)
# Rendering output
\x1b[1;1H\x1b[36m┌──────────┐\x1b[0m
\x1b[2;1H\x1b[36m│ Hello │\x1b[0m
\x1b[3;1H\x1b[36m└──────────┘\x1b[0m
The Intuition
Browser React vs Terminal React (Ink)
Ink maps every browser React concept to a terminal equivalent. The mental model is identical — only the output target changes.
| Browser React | Terminal React (Ink) |
|---|---|
ReactDOM.createRoot() | createRoot() in root.ts |
| DOM nodes (div, span) | Virtual nodes (Box, Text) |
| CSS for layout | Yoga (flexbox engine) |
| Browser renders pixels | ANSI escape codes to stdout |
requestAnimationFrame | frame.ts schedules redraws |
| Click / mouse events | parse-keypress.ts for keyboard |
Why React in a Terminal?
Terminal UIs have the same problems as web UIs: state management, conditional rendering, component composition, event handling. Before Ink, terminal apps used imperative libraries like blessed/ncurses — manually tracking cursor positions, redrawing regions, managing focus. Ink brings React's declarative model: describe what the UI should look like given current state, and the renderer figures out the minimal ANSI updates.
The Custom Reconciler
React's core (reconciliation, fiber scheduling, hooks) is target-agnostic. The react-reconciler package lets you plug in a host config that maps React operations to any target. Ink implements ~25 methods: createInstance() creates virtual terminal nodes, commitUpdate() applies prop changes, and the tree is walked to produce ANSI output.
Yoga Layout Engine
Yoga is Facebook's cross-platform flexbox implementation in C++. In the browser, CSS layout operates on pixel dimensions. In the terminal, the unit is a character cell — a fixed-width grid (e.g., 80 columns × 24 rows). Ink's <Box> maps to a Yoga node with flexbox properties: flexDirection, justifyContent, padding — all in character units.
Frame Scheduling
Browsers use requestAnimationFrame to batch DOM updates at 60fps. Ink uses a similar mechanism: state changes are batched, Yoga recalculates layout, and the full terminal output is re-rendered in one write to stdout. This prevents flickering — partial updates would show half-drawn frames.
TTY Raw Mode — The Hidden Prerequisite
Before Ink can receive individual keystrokes, it must switch the terminal from cooked mode to raw mode via process.stdin.setRawMode(true). In cooked mode (the default), the OS buffers input until the user presses Enter — only complete lines reach the process. In raw mode, every keypress is delivered immediately as a byte sequence. This is what makes interactive TUIs possible: Ink's parse-keypress.ts translates raw byte sequences into named keys ({name: 'tab', shift: false}, {name: 'return'}) that components receive via hooks like useInput(). The tradeoff: raw mode disables OS-level editing (Ctrl+C no longer sends SIGINT automatically), so Ink must handle these signals itself — it re-raises SIGINT on Ctrl+C so the process can still be interrupted.
Alternate Screen Buffer
Rich TUIs like Claude Code's interactive prompts use the terminal's alternate screen buffer — a separate display layer activated with the ANSI escape sequence \x1b[?1049h and deactivated with \x1b[?1049l. Switching to the alternate buffer saves the current terminal contents, gives a clean canvas for the TUI, and restores everything on exit — which is why running vim or lessdoesn't clobber your shell history. Ink uses this pattern for full-screen components but falls back to inline rendering (no alternate buffer) for streaming output like code generation, which must scroll with the terminal.
Why use React to build a terminal UI instead of just printing strings?
Key Code Patterns
Ink Reconciler
// Translates React tree to terminal output
class InkReconciler {
createInstance(type: string, props: Record<string, unknown>): VirtualNode {
if (type === "Box") return new VirtualBox(props); // like <div>
if (type === "Text") return new VirtualText(props); // like <span>
throw new Error(`Unknown type: ${type}`);
}
commitUpdate(node: VirtualNode, newProps: Record<string, unknown>): void {
node.update(newProps);
scheduleRerender();
}
appendChild(parent: VirtualNode, child: VirtualNode): void {
parent.children.push(child);
}
removeChild(parent: VirtualNode, child: VirtualNode): void {
parent.children = parent.children.filter(c => c !== child);
}
}Terminal Rendering Pipeline
function renderToTerminal(rootNode: VirtualNode): void {
// 1. Yoga calculates layout (flexbox in char cells)
const yogaTree = calculateLayout(rootNode, { cols: 80, rows: 24 });
// 2. Walk tree, generate ANSI escape codes
let output = "";
for (const node of walk(yogaTree)) {
const { left: x, top: y } = node.layout;
output += `\x1b[${y + 1};${x + 1}H`; // move cursor
output += colorize(node.content, node.style); // apply colors
}
// 3. Atomic write to stdout (prevents flickering)
process.stdout.write(output);
}
// Frame scheduling — batch state changes
const pendingRenders: boolean[] = [];
let rootNode: VirtualNode | null = null; // set once at startup
function scheduleRerender(): void {
if (pendingRenders.length === 0) {
setImmediate(flushRenders); // next tick
}
pendingRenders.push(true);
}
function flushRenders(): void {
pendingRenders.length = 0;
if (rootNode) renderToTerminal(rootNode); // rootNode must be defined
}Focus Management
class FocusManager {
// Tab-based focus navigation for terminal components
private focusable: VirtualNode[] = []; // ordered list of focusable nodes
private activeIndex: number = 0;
register(node: VirtualNode): void {
this.focusable.push(node);
}
handleKey(key: string): void {
if (key === "tab") {
this.activeIndex = (this.activeIndex + 1) % this.focusable.length;
} else if (key === "shift+tab") {
this.activeIndex = (this.activeIndex - 1 + this.focusable.length) % this.focusable.length;
}
this.focusable[this.activeIndex].focus();
scheduleRerender();
}
}
// In React: useFocus() hook returns { isFocused: boolean }
// Equivalent to document.activeElement in the browserBreak It — See What Happens
Real-World Numbers
| Aspect | Browser React | Terminal React (Ink) |
|---|---|---|
| Output target | DOM nodes | ANSI escape codes |
| Layout engine | CSS (browser) | Yoga (flexbox) |
| Units | Pixels | Character cells |
| Events | Mouse clicks | Keypresses |
| Frame rate | 60fps (rAF) | On-demand (state change) |
| Text measurement | Complex (variable-width fonts) | Simple (monospace = strlen) |
Key Takeaways
What to remember for interviews
- 1Ink is a custom React renderer for terminals — the same useState, useEffect, and component model as browser React, but output is ANSI escape codes written to stdout instead of DOM nodes.
- 2The react-reconciler host config (~25 methods) is the only target-specific layer; React's diffing, fiber scheduling, and hooks are completely target-agnostic.
- 3Yoga (Facebook's C++ flexbox engine) handles layout in character-cell units instead of pixels — Box maps to div, Text maps to span, and monospace fonts make text measurement trivial (strlen = display width).
- 4State changes are batched and flushed in a single atomic stdout.write() to prevent flickering — the same role requestAnimationFrame plays in browsers.
- 5TTY raw mode (setRawMode(true)) delivers individual keystrokes immediately instead of waiting for Enter; Ink must re-raise signals like Ctrl+C itself since the OS no longer handles them.
Further Reading
- Ink: React for interactive command-line apps — The production React renderer for terminals — used by Gatsby, Parcel, Prisma, and Claude Code.
- React Reconciler documentation — The official package for building custom React renderers — the foundation Ink is built on.
- Yoga Layout — Facebook's cross-platform flexbox layout engine used by Ink, React Native, and Litho.
- Blessed: A high-level terminal interface library — The older, imperative approach to terminal UIs — useful comparison point to understand what React TUI improves on.
- ANSI Escape Codes Reference — The low-level sequences that all TUI libraries emit — understanding CSI codes (cursor movement, color, erase) demystifies what the reconciler produces.
- Textual: Python TUI Framework — The Python equivalent of Ink with CSS-style layout — useful cross-language comparison of the reactive TUI approach.
- Building a Custom React Renderer — Step-by-step walkthrough of building a React custom renderer — the same techniques Ink uses to target the terminal instead of the DOM.
What is the correct order of Ink's rendering pipeline from React component to terminal output?
How do Yoga layout units differ from browser CSS units in a terminal context?
What is the minimum set of methods a custom React reconciler host config must implement to support `createInstance` and `commitUpdate`?
For a CLI tool with a real-time multi-panel dashboard (live log tail, metrics chart, input form), which TUI approach has the better tradeoff?
Interview Questions
Showing 4 of 4
How would you build a custom React renderer for a non-browser target?
★★★Compare the tradeoffs of a full React TUI vs a simpler curses-based approach.
★★☆How does flexbox layout work in a terminal context?
★★☆How would you implement progressive rendering of tool results that arrive out of order?
★★★