Back to Blog
When Your CLI Needs a Real UI: Building Interactive Terminals with React and Ink
9 min readAug 2, 20264 views

When Your CLI Needs a Real UI: Building Interactive Terminals with React and Ink

Command-line tools are powerful, but sometimes you need more than simple text output. When a complex workflow or real-time feedback calls for an actual user interface, traditional CLI development quickly becomes a pain. That's where Ink, a React renderer for the terminal, changes the game.

ToolingReactAutomationWeb DevelopmentFrontend
Share

by Sunil Band

Your CLI Deserves a UI, Not Just a Prompt

We've all built command-line tools. They're fantastic for automation, scripting, and quickly getting things done without the overhead of a GUI. But there's a point where simple console.log and readline fall short. What happens when your CLI needs to display progress bars, dynamic lists, real-time logs, or accept complex multi-step input? Suddenly, you're wrestling with ANSI escape codes, cursor positioning, and state management in a way that feels incredibly low-level and brittle.

This isn't about replacing all your simple scripts with a full UI. It's about recognizing that some CLI experiences genuinely benefit from a more interactive, graphical presentation within the terminal itself. Think about git status, npm install, or even htop – these aren't just dumping text; they're rendering a dynamic, responsive interface. Trying to build something similar from scratch in Node.js is a nightmare. That's where Ink comes in, giving us the power of React to build these rich terminal UIs.

Why React in the Terminal?

The brilliance of Ink is that it brings the entire React paradigm – components, state, hooks, reconciliation – to the terminal. You get a declarative way to describe your UI, letting Ink worry about the low-level rendering details. This immediately solves several critical problems with traditional interactive CLI development:

  1. State Management: React's component-based state model is perfect for managing the dynamic parts of a terminal UI, like a changing progress percentage or a list of items being processed.
  2. Declarative UI: Instead of imperatively printing and clearing lines, you describe what your UI should look like for a given state, and React handles the efficient updates.
  3. Composability: Complex interactive elements can be broken down into smaller, reusable components, just like in web development. This makes your terminal UI code much more maintainable and scalable.

It's not just a novelty. For any CLI that moves beyond simple one-off commands and into long-running processes, real-time monitoring, or guided interactive flows, Ink transforms the developer experience. You're building applications, not just scripts.

Building a Dynamic Progress Tracker

Let's walk through a common scenario: you have a long-running process in your CLI, and you want to show a live progress bar and status updates. Without Ink, you'd be clearing lines, re-rendering, and managing cursor positions manually. It's a mess.

With Ink, we can build a ProgressBar component that updates itself based on props. We'll also add a StatusMessage component for dynamic text. The core idea is to treat our terminal output as a React tree.

First, you'll need to install Ink and React:

bash
npm install ink react @types/react

Now, let's create a simple CLI application. We'll simulate a task that takes some time and updates its progress.

typescript
import React, { useState, useEffect } from 'react';
import { render, Text, Box } from 'ink';
import Gradient from 'ink-gradient';
import BigText from 'ink-big-text';

// A component to display a dynamic progress bar
const ProgressBar = ({ progress }: { progress: number }) => {
    const barLength = 20;
    const completed = Math.floor(barLength * (progress / 100));
    const remaining = barLength - completed;
    const progressBar = '▇'.repeat(completed) + '—'.repeat(remaining);

    return (
        <Box>
            <Text>[
                <Text color="cyan">{progressBar}</Text>
            ]
            </Text>
            <Text> {progress.toFixed(0)}%</Text>
        </Box>
    );
};

// A component for different status messages
const StatusMessage = ({ status }: { status: string }) => {
    let color = 'white';
    if (status.includes('Starting')) color = 'yellow';
    if (status.includes('Processing')) color = 'blue';
    if (status.includes('Completed')) color = 'green';
    if (status.includes('Failed')) color = 'red';

    return (
        <Text color={color}>{status}</Text>
    );
};

const App = () => {
    const [progress, setProgress] = useState(0);
    const [status, setStatus] = useState('Starting task...');
    const [step, setStep] = useState(0);

    useEffect(() => {
        const steps = [
            { message: 'Initializing...', delay: 1000 },
            { message: 'Fetching data...', delay: 1500 },
            { message: 'Processing records...', delay: 2000 },
            { message: 'Writing output...', delay: 1000 },
            { message: 'Cleaning up...', delay: 500 }
        ];

        if (step < steps.length) {
            const timer = setTimeout(() => {
                setStatus(steps[step].message);
                setStep(s => s + 1);
                setProgress(p => Math.min(100, p + 20)); // Increment progress
            }, steps[step].delay);
            return () => clearTimeout(timer);
        } else if (progress < 100) {
            // Ensure progress bar hits 100% after all steps are done
            setProgress(100);
            setStatus('Task completed successfully!');
        }
    }, [step, progress]);

    // Main render logic for the terminal UI
    return (
        <Box flexDirection="column" padding={1}>
            <Gradient name="rainbow">
                <BigText text="CLI Task Runner" font="chrome" />
            </Gradient>
            <Box marginBottom={1}>
                <Text>Current Status: </Text>
                <StatusMessage status={status} />
            </Box>
            <ProgressBar progress={progress} />
            {progress === 100 && (
                <Box marginTop={1}>
                    <Text color="green">✨ All operations finished!</Text>
                </Box>
            )}
        </Box>
    );
};

// Render the React component into the terminal
render(<App />);

To run this, save it as cli.tsx (or cli.jsx if you prefer JavaScript without TypeScript), compile it (e.g., with tsc cli.tsx && node cli.js or ts-node cli.tsx), and watch it go. You'll see a dynamically updating UI right in your terminal, complete with a progress bar and changing status messages.

Notice how useEffect and useState are used exactly as you would in a web application. The render(<App />) call is the entry point, just like ReactDOM.render for the browser. Ink provides its own set of components like Text and Box that map to terminal display concepts. I also pulled in ink-gradient and ink-big-text to show how easy it is to extend Ink with community components, similar to how you'd use libraries like Material UI or Ant Design.

The Real Power: Handling User Input and Real-time Interaction

The example above is great for displaying information, but Ink really shines when you need interactive input. Imagine a CLI where you prompt the user with choices, or allow them to type in dynamic fields. Ink provides hooks like useInput and useFocus to manage these scenarios.

Let's extend our example to ask the user if they want to run another task after the first one completes:

typescript
import React, { useState, useEffect } from 'react';
import { render, Text, Box, useInput, useApp } from 'ink'; // Added useApp for exit
import Gradient from 'ink-gradient';
import BigText from 'ink-big-text';

// ProgressBar and StatusMessage components are the same as before
const ProgressBar = ({ progress }: { progress: number }) => {
    const barLength = 20;
    const completed = Math.floor(barLength * (progress / 100));
    const remaining = barLength - completed;
    const progressBar = '▇'.repeat(completed) + '—'.repeat(remaining);

    return (
        <Box>
            <Text>[
                <Text color="cyan">{progressBar}</Text>
            ]
            </Text>
            <Text> {progress.toFixed(0)}%</Text>
        </Box>
    );
};

const StatusMessage = ({ status }: { status: string }) => {
    let color = 'white';
    if (status.includes('Starting')) color = 'yellow';
    if (status.includes('Processing')) color = 'blue';
    if (status.includes('Completed')) color = 'green';
    if (status.includes('Failed')) color = 'red';

    return (
        <Text color={color}>{status}</Text>
    );
};

const App = () => {
    const { exit } = useApp(); // Hook to exit the Ink app
    const [progress, setProgress] = useState(0);
    const [status, setStatus] = useState('Starting task...');
    const [step, setStep] = useState(0);
    const [taskFinished, setTaskFinished] = useState(false);
    const [shouldRestart, setShouldRestart] = useState<boolean | null>(null);

    useEffect(() => {
        if (taskFinished && shouldRestart === null) return; // Wait for user input

        if (shouldRestart === true) {
            // Reset state to run again
            setProgress(0);
            setStatus('Restarting task...');
            setStep(0);
            setTaskFinished(false);
            setShouldRestart(null);
            return;
        } else if (shouldRestart === false) {
            exit(); // Exit if user chooses not to restart
            return;
        }

        const steps = [
            { message: 'Initializing...', delay: 1000 },
            { message: 'Fetching data...', delay: 1500 },
            { message: 'Processing records...', delay: 2000 },
            { message: 'Writing output...', delay: 1000 },
            { message: 'Cleaning up...', delay: 500 }
        ];

        if (step < steps.length) {
            const timer = setTimeout(() => {
                setStatus(steps[step].message);
                setStep(s => s + 1);
                setProgress(p => Math.min(100, p + 20));
            }, steps[step].delay);
            return () => clearTimeout(timer);
        } else if (progress < 100) {
            setProgress(100);
            setStatus('Task completed successfully!');
            setTaskFinished(true);
        }
    }, [step, progress, taskFinished, shouldRestart, exit]);

    useInput((input, key) => {
        if (taskFinished && shouldRestart === null) {
            if (input === 'y' || key.return) {
                setShouldRestart(true);
            } else if (input === 'n') {
                setShouldRestart(false);
            }
        }
    });

    return (
        <Box flexDirection="column" padding={1}>
            <Gradient name="rainbow">
                <BigText text="CLI Task Runner" font="chrome" />
            </Gradient>
            <Box marginBottom={1}>
                <Text>Current Status: </Text>
                <StatusMessage status={status} />
            </Box>
            <ProgressBar progress={progress} />
            {taskFinished && shouldRestart === null && (
                <Box marginTop={1}>
                    <Text color="green">✨ All operations finished! Run again? (y/n) </Text>
                </Box>
            )}
            {shouldRestart === true && (
                <Box marginTop={1}>
                    <Text color="yellow">Restarting...</Text>
                </Box>
            )}
            {shouldRestart === false && (
                <Box marginTop={1}>
                    <Text color="gray">Exiting. Goodbye!</Text>
                </Box>
            )}
        </Box>
    );
};

render(<App />);

Now, after the task finishes, you'll see a prompt. Type y to restart the process, or n to exit. The useInput hook provides raw input, and useApp().exit() allows you to programmatically terminate the Ink application. This pattern is incredibly powerful for building guided flows, confirmation prompts, or even simple text-based games directly in the terminal.

Trade-offs and When Not to Use Ink

While Ink is a phenomenal tool, it's not a silver bullet. Here are a few considerations:

  • Overhead for Simple Scripts: For a CLI that just prints a few lines and exits, Ink is overkill. The dependency on React and the Ink renderer adds a bit of bundle size and startup time. Stick to console.log for the simplest cases.
  • Debuggability: Debugging a terminal UI can sometimes be trickier than a web UI, especially when dealing with layout issues or unexpected cursor behavior across different terminals. You don't have browser dev tools.
  • Limited Visuals: You're still constrained by what a terminal can render. Complex graphics, images, or elaborate typography are mostly out of reach. Ink excels at structured text, colors, and basic ASCII art, but it's not a replacement for a full desktop application framework.
  • Learning Curve: If you're not already familiar with React, there's a learning curve for both React itself and Ink's specific components and hooks. However, for a React developer, it's incredibly familiar.

Use Ink when your CLI needs more than linear output: when you need to update content in place, show complex data structures, provide interactive choices, or give real-time feedback that goes beyond simple logs. It's for CLIs that are applications themselves, rather than just scripts.

Wrapping up

Ink changes the game for complex CLI development. By bringing the declarative power of React to the terminal, it transforms what's possible with command-line interfaces. No longer are you stuck with archaic ANSI escape codes or convoluted imperative logic for dynamic displays. You can build interactive, responsive, and maintainable terminal applications using patterns you already know and love from web development.

The next time you're prototyping a new CLI tool or looking to improve an existing one that needs more than basic text output, give Ink a serious look. Clone the example code I've provided, experiment with useInput, useFocus, and explore some of the community Ink components like ink-select-input or ink-text-input. You'll quickly see how it elevates your CLI experience from basic scripts to robust terminal applications.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕