
When Your CLI Needs to Be a First-Class App: React for the Terminal with Ink
Building interactive command-line interfaces often means wrestling with low-level terminal escape codes or clunky libraries. Ink lets you use React's familiar component model to craft rich, dynamic CLIs that feel like native applications.
by Sunil Band
Your CLI Deserves Better Than console.log
We build powerful frontend applications with rich UIs, intricate state management, and smooth user experiences. Then, when it comes to our developer tools – the CLIs we use every day – we often settle for rudimentary console.log statements, spinner libraries that break layout, and a generally disjointed experience. Why should the tools that power our development workflow feel so… basic?
I'm talking about more than just a yargs wrapper. I mean genuinely interactive, dynamic interfaces that react to user input, update in real-time, and present complex information with clarity. Traditional CLI development often means diving into low-level terminal escape codes, managing cursor positions, and manually redrawing parts of the screen. It's tedious, error-prone, and far from declarative.
This is where Ink changes the game. It allows you to build interactive command-line interfaces using the same component-based paradigm and declarative style you already know and love from React. Suddenly, your CLI can have dynamic states, re-render efficiently, and offer a much richer user experience without reinventing the wheel.
The React Paradigm, for Your Terminal
At its core, Ink is a React renderer for the terminal. Think of it like React DOM renders to the browser, or React Native renders to mobile platforms. Ink renders your React components to stdout, but with a crucial difference: it handles all the complexities of updating the terminal output efficiently. This means you get features like state management, component lifecycle methods, and composition, all applied to text and ASCII art.
Why does this matter? Because you're no longer thinking about how to draw a line, clear a screen, or move a cursor. You're thinking about what your UI should look like given its current state. Your component renders, Ink calculates the diff, and only the necessary parts of the terminal are updated. This is a massive leap in productivity and maintainability for complex CLIs.
Let's consider a simple example: a progress bar. Without Ink, you'd be tracking progress, clearing lines, printing new lines, and handling potential race conditions. With Ink, it's just another component.
import React, { useState, useEffect } from 'react';
import { render, Box, Text } from 'ink';
interface ProgressBarProps {
progress: number;
}
const ProgressBar: React.FC<ProgressBarProps> = ({ progress }) => {
const filledWidth = Math.min(Math.max(0, progress), 100); // Ensure progress is between 0 and 100
const emptyWidth = 100 - filledWidth;
return (
<Box>
<Text color="green">{Array(filledWidth).fill('█').join('')}</Text>
<Text color="gray">{Array(emptyWidth).fill('░').join('')}</Text>
<Text> {Math.round(progress)}%</Text>
</Box>
);
};
const App: React.FC = () => {
const [progress, setProgress] = useState(0);
useEffect(() => {
if (progress >= 100) return;
const timer = setInterval(() => {
setProgress(prevProgress => Math.min(prevProgress + 5, 100));
}, 200);
return () => clearInterval(timer);
}, [progress]);
return (
<Box flexDirection="column" padding={1}>
<Text>Downloading your files...</Text>
<ProgressBar progress={progress} />
{progress === 100 && <Text color="green">Download complete!</Text>}
</Box>
);
};
render(<App />);This code should look instantly familiar to any React developer. We have useState for internal component state, useEffect for side effects (like our timer), and components (Box, Text) for layout and styling. Ink provides primitives like Box (think div for the terminal) and Text (think span) that map directly to Flexbox concepts, making layout intuitive.
When you run this, you'll see a smooth, animated progress bar that updates in place. No flickering, no manual cursor management. Ink handles all of that under the hood, making your CLI development feel like frontend development.
Building Interactive Experiences
The real power of Ink shines when you need user input and dynamic updates. Consider a scenario where you're building a CLI that needs to guide a user through a series of choices or display real-time data from a long-running process. Ink makes these patterns trivial.
Ink provides hooks like useInput for handling keyboard events and useApp for controlling the Ink application lifecycle (e.g., exiting gracefully). This allows you to build complex navigational patterns, forms, and interactive dashboards directly in your terminal.
Let's extend our example to include user input, allowing the user to confirm the download or cancel it.
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
const ProgressBar: React.FC<{ progress: number }> = ({ progress }) => {
const filledWidth = Math.min(Math.max(0, progress), 100);
const emptyWidth = 100 - filledWidth;
return (
<Box>
<Text color="green">{Array(filledWidth).fill('█').join('')}</Text>
<Text color="gray">{Array(emptyWidth).fill('░').join('')}</Text>
<Text> {Math.round(progress)}%</Text>
</Box>
);
};
const App: React.FC = () => {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<'idle' | 'downloading' | 'paused' | 'complete' | 'cancelled'>('idle');
const { exit } = useApp(); // Hook to exit the Ink app
useInput((input, key) => {
if (key.escape) { // Allow escape key to cancel/exit
if (status === 'downloading' || status === 'paused') {
setStatus('cancelled');
}
exit();
} else if (input === 's' && status === 'idle') { // 's' to start
setStatus('downloading');
} else if (input === 'p' && status === 'downloading') { // 'p' to pause
setStatus('paused');
} else if (input === 'r' && status === 'paused') { // 'r' to resume
setStatus('downloading');
}
});
useEffect(() => {
if (status !== 'downloading' || progress >= 100) return;
const timer = setInterval(() => {
setProgress(prevProgress => Math.min(prevProgress + 5, 100));
}, 200);
return () => clearInterval(timer);
}, [status, progress]);
useEffect(() => {
if (progress === 100) {
setStatus('complete');
// exit(); // Automatically exit on completion if desired
}
}, [progress]);
return (
<Box flexDirection="column" padding={1}>
{status === 'idle' && <Text>Press 's' to start download.</Text>}
{status === 'downloading' && (
<>
<Text>Downloading your files... (Press 'p' to pause, 'Esc' to cancel)</Text>
<ProgressBar progress={progress} />
</>
)}
{status === 'paused' && (
<>
<Text color="yellow">Download paused. Press 'r' to resume, 'Esc' to cancel.</Text>
<ProgressBar progress={progress} />
</>
)}
{status === 'complete' && <Text color="green">Download complete! Press 'Esc' to exit.</Text>}
{status === 'cancelled' && <Text color="red">Download cancelled. Press 'Esc' to exit.</Text>}
</Box>
);
};
render(<App />);This is a significantly more complex interaction, but thanks to React's declarative nature, the code remains readable and manageable. We're using useInput to listen for specific key presses and update our status state. Depending on the status, different parts of our UI are rendered. This is exactly how you'd build a web application, just with different primitives.
Trade-offs and Considerations
While Ink is powerful, it's not a silver bullet for every CLI. Here are a few things to keep in mind:
- Performance for Rapid Updates: While Ink is optimized for efficient rendering, extremely rapid, constant updates (e.g., a stock ticker updating every millisecond) might still show some flicker depending on terminal capabilities and system load. For most interactive CLI use cases, it's perfectly fine.
- Bundle Size: Ink and its dependencies add some overhead compared to a minimalist
console.logscript. For tiny utility scripts that just print a single line, it might be overkill. For anything with interactivity or a dynamic display, the developer experience benefits far outweigh this. - Terminal Compatibility: While Ink does an excellent job abstracting away terminal differences, some older or less common terminals might have quirks. It's generally stable for modern terminals (iTerm2, Alacritty, VS Code integrated terminal, etc.).
- Debugging: Debugging a React application in the terminal isn't quite as straightforward as in a browser with dev tools. You'll rely more on
console.logand careful state inspection, similar to debugging React Native applications without a debugger attached.
Despite these, for any CLI that needs more than static output, Ink is a superior choice. The ability to leverage your existing React knowledge, component libraries (like ink-spinner, ink-select-input), and tooling makes it incredibly productive.
Wrapping up
If you've been building CLIs with a patchwork of imperative code and wish you could bring the elegance and power of React to your terminal, Ink is the answer. It elevates the CLI user experience from a series of static prints to a dynamic, interactive application. Your tools should be as powerful and pleasant to use as the applications they help you build.
To get started, create a new Node.js project, install ink and react, and try running one of the examples above. You'll quickly see how intuitive it is. Then, explore the Ink documentation and its ecosystem of components to build truly first-class CLIs for your projects. You can literally copy-paste the ProgressBar example above into an index.tsx (or .jsx) file, add "type": "module" to your package.json, and run it with node index.tsx (after npm i ink react). It's that easy.

When Your Data Visualization Needs to Be More Than Just a Static Chart: Diving into Plotly.js
Tired of static charts that only tell half the story? Plotly.js offers a powerful way to bring your data to life with rich interactivity, letting users explore and understand complex datasets directly in their browser. It's not just about pretty graphs; it's about making data explorable.

When Your Markdown Notes Need to Act Like a Database: Diving into SilverBullet
I've been using Markdown for notes and documentation for years, but it always felt like I was leaving so much on the table. How do you query across files? How do you create dynamic dashboards? SilverBullet finally tackles this, turning simple Markdown into a surprisingly powerful, queryable knowledg

When Your E2E Tests Need to Be More Than Just Clicking Buttons: Diving into Playwright's API Capabilities
Most developers use Playwright for UI automation, clicking through pages and asserting on elements. But its underlying API client is a hidden gem, allowing you to combine UI interactions with direct API calls for faster, more robust end-to-end tests.


















