Because that’s apparently the only place React wasn’t being used… until now, or actually a few years ago. I am late to the party, but I saw the dependency inside OpenAI’s Codex repo and had to play around with it.
Look, we’ve all been there. You’re building a CLI tool that spits out text like it’s 1983, and you’re thinking, “Wouldn’t it be nice if this looked… you know… not terrible?” Then you remember you’re a JavaScript developer in 2025, which means you’re contractually obligated to use React for everything, including your coffee maker and refrigerator.
Good news: the terminal is no longer safe from React’s creeping influence! Thanks to ink.js, you can now inflict your JSX addiction on command-line users everywhere. Let’s see how to build beautiful CLI interfaces that’ll make users forget they’re staring at a terminal.
What the hell is ink.js anyway?
Ink is React for your terminal. Yes, you read that correctly. It lets you build CLI apps with components, hooks, and all the React goodness you’ve grown attached to. It’s like if React and your terminal had a baby, and that baby grew up to have surprisingly good taste in UI design.
Created by Vadim Demedes in 2017 and now maintained by Sindre Sorhus, Ink provides the same component-based UI building experience that React offers in the browser, but for command-line apps. It uses Yoga to build Flexbox layouts in the terminal, so most CSS-like props are available in Ink as well. If you’re already familiar with React, you already know Ink.
Ink has been adopted by numerous projects, including:
- OpenAI’s Codex CLI: A lightweight coding agent that runs in your terminal. https://github.com/openai/codex
- GitHub Copilot for CLI: An extension for GitHub CLI that provides a chat-like interface in the terminal, allowing you to ask questions about the command line.
- Cloudflare’s Wrangler: The CLI for Cloudflare Workers, allowing you to deploy serverless code instantly across the globe.
- Shopify CLI: A command-line interface tool that helps you generate and work with Shopify apps, themes, and custom storefronts.
- And many others! The full list can be found here — https://github.com/vadimdemedes/ink?tab=readme-ov-file#whos-using-ink
Getting started
(AKA “Hello World” because it’s a CLI tool and a TODO app won’t cut it here)
First, let’s set up a basic project. I’m assuming you have Node.js installed and know what npm is. If not, I’m genuinely curious how you found this article.
mkdir fancy-cli
cd fancy-cli
npm init -y
npm install ink react
Create a simple hello.jsx file:
import React from 'react';
import { render, Text } from 'ink';
render(<Text color="green">Hello from the fancy future of CLIs!</Text>);
Install the dependencies and run:
npm install ink react tsx
tsx hello.jsx
That’s it! The tsx package handles all the transpilation for you without any configuration.
Behold! Green text! Revolutionary! The 1970s called and they’re actually pretty impressed because color terminals weren’t widely available until the 1980s.
Making things a bit more interesting
Let’s build something with a bit more personality — a loading spinner with a progress bar, because nothing says “I’m doing important computer things” like a progress bar.
import React, { useState, useEffect } from 'react';
import { render, Box, Text } from 'ink';
import Spinner from 'ink-spinner';
const App = () => {
const [progress, setProgress] = useState(0);
const [isComplete, setIsComplete] = useState(false);
useEffect(() => {
const timer = setInterval(() => {
setProgress(oldProgress => {
if (oldProgress === 100) {
clearInterval(timer);
setIsComplete(true);
return 100;
}
return oldProgress + 5;
});
}, 100);
return () => {
clearInterval(timer);
};
}, []);
const filledLength = Math.round(progress / 5);
const bar = '█'.repeat(filledLength) + '░'.repeat(20 - filledLength);
return (
<Box flexDirection="column">
<Box>
{!isComplete ? (
<>
<Text color="green">
<Spinner type="dots" />
</Text>
<Text> Processing your very important data</Text>
</>
) : (
<Text color="green">✓ Done! Processing complete</Text>
)}
</Box>
<Box marginTop={1}>
<Text>{bar} {progress}%</Text>
</Box>
</Box>
);
};
render(<App />);
First, install the spinner:
npm install ink-spinner
Run it and witness the glory of a terminal app that doesn’t look like it was designed during the Cold War.
Building a Codex-like CLI Interface
Now let’s get serious. OpenAI’s Codex CLI (and its competitors) are known for clean, informative interfaces with distinct sections, syntax highlighting, and a natural conversational flow. Here’s how to build something similar:
import React, { useState } from 'react';
import { render, Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import TextInput from 'ink-text-input';
const CodexCLI = () => {
const [input, setInput] = useState('');
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (value) => {
if (value.trim() === '') return;
// Add user message
setMessages([...messages, { role: 'user', content: value }]);
setInput('');
setIsLoading(true);
// Simulate AI response
setTimeout(() => {
setIsLoading(false);
setMessages(prev => [
...prev,
{
role: 'assistant',
content: `Here's some example code for "${value}":`,
code: `function example() {\n // This would handle: ${value}\n console.log("Processing ${value}");\n return true;\n}`
}
]);
}, 1500);
};
return (
<Box flexDirection="column" padding={1}>
<Box borderStyle="round" borderColor="cyan" padding={1} marginBottom={1}>
<Text bold>Codex-like CLI</Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
{messages.map((message, i) => (
<Box key={i} flexDirection="column" marginBottom={1}>
<Text color={message.role === 'user' ? 'yellow' : 'green'} bold>
{message.role === 'user' ? '🧑 You:' : '🤖 Assistant:'}
</Text>
<Box marginLeft={2} marginTop={1}>
<Text>{message.content}</Text>
</Box>
{message.code && (
<Box marginLeft={2} marginTop={1} marginBottom={1} borderStyle="round" paddingX={1}>
<Text color="cyan">{message.code}</Text>
</Box>
)}
</Box>
))}
{isLoading && (
<Box marginBottom={1}>
<Text color="green" bold>🤖 Assistant: </Text>
<Text>
<Spinner type="dots" />
<Text> Thinking...</Text>
</Text>
</Box>
)}
</Box>
<Box borderStyle="single" borderColor="gray">
<Text>❯ </Text>
<TextInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
placeholder="Ask something..."
/>
</Box>
<Box marginTop={1}>
<Text dimColor>Press Ctrl+C to exit</Text>
</Box>
</Box>
);
};
render(<CodexCLI />);
This gives you a nice, interactive CLI with:
- User/assistant chat-like interface
- Loading indicators
- Syntax highlighting
- Styled input
- Bordered sections
Taking it Further
Of course, in a real Codex-like CLI, you’d connect to an actual API instead of our setTimeout fakery. Here’s how you might integrate with, say, the OpenAI API:
import fetch from 'node-fetch';
const generateResponse = async (messages) => {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPEN_API_KEY}`
},
body: JSON.stringify({
model: 'gpt-4',
max_tokens: 150,
messages
})
});
const data = await response.json();
// Check for errors or undefined values
if (!data) {
throw new Error('Empty response from API');
}
if (data.error) {
throw new Error(`API Error: ${data.error.message || JSON.stringify(data.error)}`);
}
if (!data.choices || !data.choices.length) {
throw new Error('No choices returned from API');
}
return data.choices[0]?.message?.content?.trim();
};
Then use it in your handleSubmit function:
const handleSubmit = async (value) => {
if (value.trim() === '') return;
const newMessages = [
...messages,
{
role: 'user', content: value
}
]
setMessages(newMessages);
setInput('');
setIsLoading(true);
const response = await generateResponse(newMessages);
// Simple regex to extract code blocks
const codeMatch = response.match(/```([\s\S]*?)```/);
setIsLoading(false);
setMessages([
...newMessages,
{
role: 'assistant',
content: codeMatch ? response.replace(/```[\s\S]*?```/, '') : response,
code: codeMatch ? codeMatch[1] : null
}
]);
};
Tips for Building Production-Ready CLI Tools with ink.js
1 — Handle Terminal Resizing: Use the useStdout hook to get terminal dimensions and adjust your UI accordingly.
import { useStdout } from 'ink';
const MyComponent = () => {
const { stdout } = useStdout();
const columns = stdout.columns || 80;
// Now adjust your UI based on available width
return <Text wrap="wrap" width={columns - 2}>...</Text>;
};
2 — Add Keyboard Navigation: Use the useInput hook for custom key handling.
import { useInput } from 'ink';
useInput((input, key) => {
if (key.escape) {
// Handle ESC key
}
if (key.return) {
// Handle Enter key
}
if (key.upArrow) {
// Handle up arrow
}
});
3 — Exit Gracefully: Make sure to clean up and exit properly.
import { useApp } from 'ink';
const { exit } = useApp();
// When you need to exit:
exit();
4 — Support Non-TTY Environments: Some users might pipe your command output, so handle that gracefully.
import { render, Text } from 'ink';
import { isCI } from 'ci-info';
// If running in CI or being piped
if (isCI || !process.stdout.isTTY) {
console.log('Running in non-interactive mode');
process.exit(0);
} else {
render(<App />);
}
5 —Check the docs: GitHub - vadimdemedes/ink-ui: 💄 Ink-redible command-line interfaces made easy
Or try to prompt/vibe things until you figure them out.
The Downsides (Yes, There Are Some)
Let’s keep it real for a minute:
- Bundle Size: You’re shipping React with your CLI tool. That’s… a choice.
- Performance: For simple CLIs, this is overkill. If you’re just printing a few lines of text, maybe stick with plain old console.log().
- Learning Curve: If your team isn’t familiar with React, this adds complexity.
- Maintenance: Now you have to worry about React upgrades affecting your CLI.
Conclusion
Ink.js brings the component-based joy of React to the terminal, letting you build CLI tools that don’t look like they were designed by someone who still uses Lynx as their primary browser.
The next time you’re building a CLI and think “I wish I could use React for this,” remember: you can, and perhaps that’s both amazing and slightly concerning. Your terminal commands can now have the same developer experience as your web apps, for better or worse.
Is this peak JavaScript ecosystem or have we gone too far? I’ll let you decide. But I, for one, welcome our new React-in-the-terminal overlords. At least until someone figures out how to run Electron in the command line.
Until then, happy hacking with ink.js!
Are you using React in places it was never meant to go? Share your CLI tool abominations in the comments! I promise I’ll only judge you a little.