Ink TUI Skill
Build rich, interactive terminal UIs using Ink — React for CLIs.
Used by Claude Code, Gemini CLI, Cloudflare Wrangler, Shopify CLI, Prisma, and more.
References
references/ink-readme.md — Full official Ink API: all components, hooks, render options, testing, ARIA
references/ecosystem-components.md — Third-party components: @inkjs/ui, ink-spinner, ink-select-input, ink-table, ink-task-list, ink-form, ink-gradient, and 25+ more
references/best-practices.md — Architecture patterns, performance, input handling, testing, pitfalls
Quick Start
npx create-ink-app --typescript my-cli
# or manually:
npm install ink react @types/react
import React from 'react';
import {render, Box, Text, useInput, useApp} from 'ink';
const App = () => {
const {exit} = useApp();
useInput((input, key) => {
if (input === 'q') exit();
});
return (
<Box flexDirection="column" gap={1}>
<Text bold color="cyan">My CLI</Text>
<Text dimColor>Press q to quit</Text>
</Box>
);
};
render(<App />);
Core Components
| Component |
Purpose |
<Text> |
Render styled text (color, bold, italic, underline, wrap/truncate) |
<Box> |
Flexbox layout container — padding, margin, border, gap, flex props |
<Newline> |
Insert \n inside <Text> |
<Spacer> |
Flexible space between items |
<Static> |
Permanently rendered output (completed tasks, logs) |
<Transform> |
Transform string output (gradients, effects) |
Core Hooks
| Hook |
Purpose |
useInput(handler, {isActive}) |
Keyboard input handling |
useApp() |
{exit} — unmount the app |
useFocus({id, autoFocus, isActive}) |
{isFocused} — focusable components |
useFocusManager() |
{focusNext, focusPrevious, focus, activeId} |
useStdout() |
{write} — write outside Ink's output |
useStdin() |
{isRawModeSupported, setRawMode} |
useCursor() |
{setCursorPosition} — IME cursor control |
useIsScreenReaderEnabled() |
Accessibility detection |
Workflow
1. Assess the TUI type needed
- Static output (progress, logs): use
<Static> + simple state
- Interactive menu: use
ink-select-input or @inkjs/ui Select
- Form: use
ink-form or @inkjs/ui TextInput
- Dashboard: compose
<Box> layout + ink-use-stdout-dimensions
- Multi-screen: use screen state +
useInput for navigation
2. Choose components from ecosystem
Consult references/ecosystem-components.md for the right package.
Prefer @inkjs/ui for standard inputs — it's the official library.
3. Structure the app
src/
cli.tsx ← render() entry point
app.tsx ← root App component (handles global input)
components/ ← reusable UI components
screens/ ← top-level screen components
4. Handle input correctly
- Use
useInput with {isActive} to prevent input conflicts between panels
- Always guard
setRawMode with isRawModeSupported
- Clean up all timers and listeners in
useEffect return
5. Performance
- Use
<Static> for completed/immutable output — never re-renders
- Set
incrementalRendering: true for frequently updating UIs
- Use
ink-virtual-list for lists with 100+ items
- Batch state updates to minimize re-renders
Common Patterns
Loading + Spinner
import Spinner from 'ink-spinner';
{loading ? (
<Text color="green"><Spinner type="dots" /> Processing...</Text>
) : (
<Text color="green">Done!</Text>
)}
Bordered Panel with Title
<Box borderStyle="round" borderColor="blue" flexDirection="column" padding={1}>
<Text bold>Panel Title</Text>
<Text>{content}</Text>
</Box>
Task List
import {TaskList, Task} from 'ink-task-list';
<TaskList>
<Task label="Step 1" state="success" />
<Task label="Step 2" state="loading" />
<Task label="Step 3" state="pending" />
</TaskList>
Keyboard-Navigated List
const [index, setIndex] = useState(0);
useInput((input, key) => {
if (key.upArrow) setIndex(i => Math.max(0, i - 1));
if (key.downArrow) setIndex(i => Math.min(items.length - 1, i + 1));
if (key.return) onSelect(items[index]);
});
return (
<Box flexDirection="column">
{items.map((item, i) => (
<Text key={item.id} color={i === index ? 'blue' : undefined}>
{i === index ? '▶ ' : ' '}{item.label}
</Text>
))}
</Box>
);
Multi-Screen App
type Screen = 'home' | 'list' | 'detail';
const [screen, setScreen] = useState<Screen>('home');
useInput((input, key) => {
if (key.escape && screen !== 'home') setScreen('home');
if (input === 'q') exit();
});
const screens: Record<Screen, JSX.Element> = {
home: <HomeScreen />,
list: <ListScreen => setScreen('detail')} />,
detail: <DetailScreen => setScreen('list')} />,
};
return screens[screen];
Terminal Dimensions
import useStdoutDimensions from 'ink-use-stdout-dimensions';
const [columns, rows] = useStdoutDimensions();
<Box width={columns} height={rows}>...</Box>
render() Key Options
render(<App />, {
exitOnCtrlC: true, // default: true
patchConsole: true, // prevent console.log conflicts
incrementalRendering: true, // only redraw changed lines
maxFps: 30, // default: 30fps
concurrent: true, // React concurrent mode + Suspense
kittyKeyboard: {mode: 'auto'}, // enhanced keyboard (kitty/WezTerm/Ghostty)
});
Testing
import {render} from 'ink-testing-library';
const {lastFrame, stdin} = render(<MyComponent prop="value" />);
expect(lastFrame()).toContain('Expected text');
stdin.write('q'); // simulate keypress
Pitfalls to Avoid
- No
<Box> inside <Text> — only text nodes and nested <Text> allowed
measureElement only works in useEffect — not during render (returns 0,0)
- Always guard
setRawMode with isRawModeSupported
- Always cleanup timers/listeners in
useEffect return
- Don't use
console.log — use useStdout().write() or rely on patchConsole
- Use
<Static> for completed output — don't re-render immutable items
- Batch state updates — separate
setState calls cause separate renders
1---2name: ink-tui3description: Build terminal UIs with Ink (React for CLIs). Use for interactive CLI apps, dashboards, menus, forms, progress indicators, and terminal-based interfaces. Use proactively when user says "build TUI", "terminal UI", "CLI interface", "ink component", "ink spinner", "ink select", "terminal dashboard", or mentions React-style terminal apps. Examples: - user: "Create an interactive CLI menu" → build with Ink + ink-select-input - user: "Add a progress bar to my CLI" → implement with ink-progress-bar or @inkjs/ui - user: "Build a terminal dashboard" → compose Box/Text with hooks and ecosystem components - user: "Make a multi-step wizard CLI" → ink-stepper or multi-screen pattern - user: "Add keyboard navigation to my CLI" → useInput, useFocus, useFocusManager4---56# Ink TUI Skill78Build rich, interactive terminal UIs using [Ink](https://github.com/vadimdemedes/ink) — React for CLIs.9Used by Claude Code, Gemini CLI, Cloudflare Wrangler, Shopify CLI, Prisma, and more.1011## References1213- `references/ink-readme.md` — Full official Ink API: all components, hooks, render options, testing, ARIA14- `references/ecosystem-components.md` — Third-party components: @inkjs/ui, ink-spinner, ink-select-input, ink-table, ink-task-list, ink-form, ink-gradient, and 25+ more15- `references/best-practices.md` — Architecture patterns, performance, input handling, testing, pitfalls1617---1819## Quick Start2021```sh22npx create-ink-app --typescript my-cli23# or manually:24npm install ink react @types/react25```2627```tsx28import React from 'react';29import {render, Box, Text, useInput, useApp} from 'ink';3031const App = () => {32 const {exit} = useApp();3334 useInput((input, key) => {35 if (input === 'q') exit();36 });3738 return (39 <Box flexDirection="column" gap={1}>40 <Text bold color="cyan">My CLI</Text>41 <Text dimColor>Press q to quit</Text>42 </Box>43 );44};4546render(<App />);47```4849---5051## Core Components5253| Component | Purpose |54|---|---|55| `<Text>` | Render styled text (color, bold, italic, underline, wrap/truncate) |56| `<Box>` | Flexbox layout container — padding, margin, border, gap, flex props |57| `<Newline>` | Insert `\n` inside `<Text>` |58| `<Spacer>` | Flexible space between items |59| `<Static>` | Permanently rendered output (completed tasks, logs) |60| `<Transform>` | Transform string output (gradients, effects) |6162## Core Hooks6364| Hook | Purpose |65|---|---|66| `useInput(handler, {isActive})` | Keyboard input handling |67| `useApp()` | `{exit}` — unmount the app |68| `useFocus({id, autoFocus, isActive})` | `{isFocused}` — focusable components |69| `useFocusManager()` | `{focusNext, focusPrevious, focus, activeId}` |70| `useStdout()` | `{write}` — write outside Ink's output |71| `useStdin()` | `{isRawModeSupported, setRawMode}` |72| `useCursor()` | `{setCursorPosition}` — IME cursor control |73| `useIsScreenReaderEnabled()` | Accessibility detection |7475---7677## Workflow7879### 1. Assess the TUI type needed80- **Static output** (progress, logs): use `<Static>` + simple state81- **Interactive menu**: use `ink-select-input` or `@inkjs/ui Select`82- **Form**: use `ink-form` or `@inkjs/ui TextInput`83- **Dashboard**: compose `<Box>` layout + `ink-use-stdout-dimensions`84- **Multi-screen**: use screen state + `useInput` for navigation8586### 2. Choose components from ecosystem87Consult `references/ecosystem-components.md` for the right package.88Prefer `@inkjs/ui` for standard inputs — it's the official library.8990### 3. Structure the app91```92src/93 cli.tsx ← render() entry point94 app.tsx ← root App component (handles global input)95 components/ ← reusable UI components96 screens/ ← top-level screen components97```9899### 4. Handle input correctly100- Use `useInput` with `{isActive}` to prevent input conflicts between panels101- Always guard `setRawMode` with `isRawModeSupported`102- Clean up all timers and listeners in `useEffect` return103104### 5. Performance105- Use `<Static>` for completed/immutable output — never re-renders106- Set `incrementalRendering: true` for frequently updating UIs107- Use `ink-virtual-list` for lists with 100+ items108- Batch state updates to minimize re-renders109110---111112## Common Patterns113114### Loading + Spinner115```tsx116import Spinner from 'ink-spinner';117118{loading ? (119 <Text color="green"><Spinner type="dots" /> Processing...</Text>120) : (121 <Text color="green">Done!</Text>122)}123```124125### Bordered Panel with Title126```tsx127<Box borderStyle="round" borderColor="blue" flexDirection="column" padding={1}>128 <Text bold>Panel Title</Text>129 <Text>{content}</Text>130</Box>131```132133### Task List134```tsx135import {TaskList, Task} from 'ink-task-list';136137<TaskList>138 <Task label="Step 1" state="success" />139 <Task label="Step 2" state="loading" />140 <Task label="Step 3" state="pending" />141</TaskList>142```143144### Keyboard-Navigated List145```tsx146const [index, setIndex] = useState(0);147148useInput((input, key) => {149 if (key.upArrow) setIndex(i => Math.max(0, i - 1));150 if (key.downArrow) setIndex(i => Math.min(items.length - 1, i + 1));151 if (key.return) onSelect(items[index]);152});153154return (155 <Box flexDirection="column">156 {items.map((item, i) => (157 <Text key={item.id} color={i === index ? 'blue' : undefined}>158 {i === index ? '▶ ' : ' '}{item.label}159 </Text>160 ))}161 </Box>162);163```164165### Multi-Screen App166```tsx167type Screen = 'home' | 'list' | 'detail';168const [screen, setScreen] = useState<Screen>('home');169170useInput((input, key) => {171 if (key.escape && screen !== 'home') setScreen('home');172 if (input === 'q') exit();173});174175const screens: Record<Screen, JSX.Element> = {176 home: <HomeScreen onNavigate={setScreen} />,177 list: <ListScreen onSelect={() => setScreen('detail')} />,178 detail: <DetailScreen onBack={() => setScreen('list')} />,179};180181return screens[screen];182```183184### Terminal Dimensions185```tsx186import useStdoutDimensions from 'ink-use-stdout-dimensions';187188const [columns, rows] = useStdoutDimensions();189<Box width={columns} height={rows}>...</Box>190```191192---193194## render() Key Options195196```tsx197render(<App />, {198 exitOnCtrlC: true, // default: true199 patchConsole: true, // prevent console.log conflicts200 incrementalRendering: true, // only redraw changed lines201 maxFps: 30, // default: 30fps202 concurrent: true, // React concurrent mode + Suspense203 kittyKeyboard: {mode: 'auto'}, // enhanced keyboard (kitty/WezTerm/Ghostty)204});205```206207---208209## Testing210211```tsx212import {render} from 'ink-testing-library';213214const {lastFrame, stdin} = render(<MyComponent prop="value" />);215expect(lastFrame()).toContain('Expected text');216stdin.write('q'); // simulate keypress217```218219---220221## Pitfalls to Avoid2222231. **No `<Box>` inside `<Text>`** — only text nodes and nested `<Text>` allowed2242. **`measureElement` only works in `useEffect`** — not during render (returns 0,0)2253. **Always guard `setRawMode` with `isRawModeSupported`**2264. **Always cleanup timers/listeners** in `useEffect` return2275. **Don't use `console.log`** — use `useStdout().write()` or rely on `patchConsole`2286. **Use `<Static>` for completed output** — don't re-render immutable items2297. **Batch state updates** — separate `setState` calls cause separate renders