Mastering Interactive CLI Prompts in Node.js: Beyond readline
Mastering Interactive CLI Prompts in Node.js: Beyond readline
Command-line interfaces have come a long way from static flags and positional arguments. Modern CLIs guide users through complex workflows with interactive prompts, spinners, progress bars, and even full text-based user interfaces. If you've ever used create-react-app, npm init, or degit, you've experienced what good interactive CLI design feels like.
In this guide, we'll move beyond Node.js's built-in readline module and explore the rich ecosystem of prompt libraries, animation utilities, and TUI frameworks that make CLI tools genuinely pleasant to use. Along the way, we'll build a complete project scaffolder CLI from scratch.
Why Interactive Prompts Transform CLI UX
Traditional CLIs rely on users memorizing flags. Consider:
mycli create --name my-project --template react --typescript --eslint --prettier --git
That's seven flags a user has to know about. Now imagine the alternative:
? What's your project name? my-project
? Pick a template: (Use arrow keys)
> React
Vue
Svelte
Vanilla
? Enable TypeScript? Yes
? Select linting tools: (Press space to select)
> [x] ESLint
[x] Prettier
[ ] Stylelint
Interactive prompts are self-documenting. Users don't need to read help text or man pages. They discover options as they go. The CLI becomes a guided conversation rather than a pop quiz.
But the benefits go beyond discoverability:
- Validation happens inline. You can reject bad input before proceeding to the next step.
- Defaults reduce friction. Power users can hit Enter through sensible defaults.
- Conditional flows are natural. If a user picks "React," you can ask about JSX transforms. If they pick "Vue," you ask about the Composition API instead.
- Error recovery is graceful. Instead of aborting with a cryptic message, you re-prompt the specific question.
The Prompts Ecosystem
Node.js's built-in readline module handles raw line input, but building interactive prompts on top of it means reimplementing cursor management, ANSI escape codes, key bindings, and validation. Several libraries abstract this away. Let's compare the major players.
Inquirer.js
The original. Inquirer has been around since 2013 and remains the most downloaded prompt library on npm. Version 9+ was rewritten as a collection of standalone ES module packages.
import input from '@inquirer/input';
import select from '@inquirer/select';
const name = await input({ message: 'Project name:' });
const framework = await select({
message: 'Framework:',
choices: [
{ name: 'React', value: 'react' },
{ name: 'Vue', value: 'vue' },
{ name: 'Svelte', value: 'svelte' },
],
});
Strengths: Massive ecosystem, well-tested, modular in v9+. Weaknesses: Heavier than alternatives, the v9 migration broke many plugins.
prompts (by terkelg)
A lightweight alternative that uses a single function call with an array of question objects. It's closer in spirit to the old Inquirer API but significantly smaller.
import prompts from 'prompts';
const response = await prompts([
{ type: 'text', name: 'name', message: 'Project name:' },
{ type: 'select', name: 'framework', message: 'Framework:', choices: [
{ title: 'React', value: 'react' },
{ title: 'Vue', value: 'vue' },
]},
]);
Strengths: Tiny footprint (~4KB), no dependencies, clean API. Weaknesses: Fewer prompt types, less active maintenance.
@clack/prompts
The newest contender, created by the Astro team's Nate Moore. Clack focuses on beautiful output with minimal API surface. It wraps prompts in visual groupings with intro() and outro().
import { intro, outro, text, select, confirm, spinner } from '@clack/prompts';
intro('create-my-app');
const name = await text({ message: 'Project name:', placeholder: 'my-app' });
const framework = await select({
message: 'Framework:',
options: [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
],
});
outro('Done! Happy coding.');
Strengths: Gorgeous output, built-in spinner, excellent cancellation handling, actively maintained. Weaknesses: Fewer prompt types than Inquirer, relatively new.
Enquirer
Built by the authors of micromatch and braces, Enquirer is performance-focused and supports prompt composition.
import Enquirer from 'enquirer';
const response = await Enquirer.prompt([
{ type: 'input', name: 'name', message: 'Project name:' },
{ type: 'autocomplete', name: 'framework', message: 'Framework:', choices: ['React', 'Vue', 'Svelte'] },
]);
Strengths: Fast, supports autocomplete natively, composable prompt classes. Weaknesses: Maintenance has slowed, TypeScript types are community-maintained.
Which Should You Choose?
For new projects in 2024+, @clack/prompts is the strongest default. It produces beautiful output, handles cancellation correctly out of the box, and has a minimal API. Use Inquirer v9 if you need a specific prompt type that Clack doesn't support (like editor or checkbox with advanced filtering). Use prompts if bundle size is critical. Use Enquirer if you need autocomplete with large datasets.
Prompt Types Deep Dive
All major libraries support these core prompt types. Understanding when to use each one is key to good CLI UX.
Text Input
The simplest prompt. Use for project names, descriptions, or any freeform string.
const name = await text({
message: 'Project name:',
placeholder: 'my-awesome-app',
defaultValue: 'my-app',
validate: (value) => {
if (!/^[a-z0-9-]+$/.test(value)) return 'Use lowercase letters, numbers, and hyphens only.';
},
});
Select (Single Choice)
Use when there's a fixed set of options and the user must pick exactly one. Arrow keys navigate, Enter confirms.
const license = await select({
message: 'License:',
options: [
{ value: 'mit', label: 'MIT', hint: 'Permissive, most popular' },
{ value: 'apache2', label: 'Apache 2.0', hint: 'Permissive with patent grant' },
{ value: 'gpl3', label: 'GPL 3.0', hint: 'Copyleft' },
],
});
Multi-Select
Like select, but the user can toggle multiple options on and off with the spacebar.
const features = await multiselect({
message: 'Additional features:',
options: [
{ value: 'eslint', label: 'ESLint' },
{ value: 'prettier', label: 'Prettier' },
{ value: 'husky', label: 'Git hooks (Husky)' },
{ value: 'tests', label: 'Testing (Vitest)' },
],
required: true,
});
Confirm (Yes/No)
A simple boolean question. Use for feature flags and opt-in decisions.
const useTypeScript = await confirm({ message: 'Use TypeScript?' });
Password
Masks input with asterisks or dots. Use for API keys, tokens, or actual passwords during setup flows.
const token = await password({ message: 'GitHub token:', mask: '*' });
Autocomplete
Not available in all libraries (Enquirer and Inquirer support it natively). Essential when the option list is large, like choosing from hundreds of npm packages or timezone identifiers.
const timezone = await autocomplete({
message: 'Timezone:',
choices: Intl.supportedValuesOf('timeZone'),
});
Building Wizard-Style Multi-Step Flows
Real CLIs rarely ask a flat list of questions. They branch based on answers. Here's a pattern for conditional flows using @clack/prompts:
import { intro, text, select, confirm, group, outro, isCancel, cancel } from '@clack/prompts';
intro('project-scaffolder');
const project = await group(
{
name: () => text({ message: 'Project name:', validate: (v) => v.length === 0 ? 'Required' : undefined }),
type: () => select({
message: 'Project type:',
options: [
{ value: 'app', label: 'Application' },
{ value: 'lib', label: 'Library' },
],
}),
framework: ({ results }) => {
if (results.type === 'lib') return select({
message: 'Build tool:',
options: [
{ value: 'tsup', label: 'tsup' },
{ value: 'rollup', label: 'Rollup' },
],
});
return select({
message: 'Framework:',
options: [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'svelte', label: 'Svelte' },
],
});
},
typescript: () => confirm({ message: 'Use TypeScript?' }),
},
{
onCancel: () => {
cancel('Setup cancelled.');
process.exit(0);
},
}
);
outro(`Creating ${project.name} with ${project.framework}...`);
The group function from Clack runs prompts sequentially, passes accumulated results to each step, and handles cancellation uniformly. This pattern eliminates the messy if/else chains that accumulate when you wire prompts together manually.
Validation and Inline Error Messages
Good validation is specific and immediate. Don't just say "Invalid input" -- tell the user exactly what's wrong and what you expect.
const port = await text({
message: 'Dev server port:',
defaultValue: '3000',
validate: (value) => {
const num = parseInt(value, 10);
if (isNaN(num)) return 'Must be a number.';
if (num < 1024) return 'Must be >= 1024 (ports below are reserved).';
if (num > 65535) return 'Must be <= 65535.';
},
});
When validation fails, most prompt libraries re-render the prompt with the error message inline, directly below the input. The user corrects their answer without losing context. This is dramatically better than the traditional pattern of parsing all arguments upfront and dumping all validation errors at once.
For async validation (checking if a project name is taken on npm, for instance):
const name = await text({
message: 'Package name:',
validate: async (value) => {
const res = await fetch(`https://registry.npmjs.org/${value}`);
if (res.ok) return `"${value}" is already taken on npm.`;
},
});
Spinners and Progress Bars
Long operations need visual feedback. Without it, users assume the CLI is frozen.
Spinners with ora
import ora from 'ora';
const spinner = ora('Installing dependencies...').start();
try {
await execa('npm', ['install'], { cwd: projectDir });
spinner.succeed('Dependencies installed.');
} catch {
spinner.fail('Installation failed.');
process.exit(1);
}
Ora provides dozens of spinner styles (dots, line, arc, bouncingBar) and supports color. For @clack/prompts, there's a built-in spinner:
import { spinner } from '@clack/prompts';
const s = spinner();
s.start('Installing dependencies...');
await execa('npm', ['install'], { cwd: projectDir });
s.stop('Dependencies installed.');
Progress Bars with cli-progress
For operations where you know the total count (downloading files, processing records), a progress bar is more informative than a spinner.
import { SingleBar, Presets } from 'cli-progress';
const bar = new SingleBar({
format: ' {bar} {percentage}% | {value}/{total} files',
}, Presets.shades_classic);
bar.start(files.length, 0);
for (const file of files) {
await processFile(file);
bar.increment();
}
bar.stop();
You can also use multi-bars for parallel operations -- downloading multiple assets simultaneously, each with its own progress bar.
Keyboard Shortcuts and Hotkeys
For long-running CLIs (dev servers, watchers), keyboard shortcuts add power-user ergonomics. Node.js's readline and raw stdin mode make this possible:
import readline from 'readline';
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) process.stdin.setRawMode(true);
console.log('Press r to restart, o to open browser, q to quit.');
process.stdin.on('keypress', (str, key) => {
if (key.name === 'r') restartServer();
if (key.name === 'o') openBrowser();
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
cleanup();
process.exit(0);
}
});
Vite uses this pattern extensively. When you run vite dev, pressing r restarts the server, o opens the browser, and h shows help. It's a small touch that makes the CLI feel like a professional tool rather than a script.
Building a TUI with blessed and Ink
When your CLI needs more than sequential prompts -- dashboards, split panes, scrollable lists -- you enter TUI territory.
Ink: React for the Terminal
Ink lets you build terminal UIs with React components. It uses Yoga (the same layout engine as React Native) for flexbox in the terminal.
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useInput } from 'ink';
function Dashboard() {
const [selected, setSelected] = useState(0);
const items = ['Build', 'Test', 'Deploy', 'Logs'];
useInput((input, key) => {
if (key.upArrow) setSelected(s => Math.max(0, s - 1));
if (key.downArrow) setSelected(s => Math.min(items.length - 1, s + 1));
});
return (
<Box flexDirection="column" padding={1}>
<Text bold>Project Dashboard</Text>
{items.map((item, i) => (
<Text key={item} color={i === selected ? 'cyan' : 'white'}>
{i === selected ? '> ' : ' '}{item}
</Text>
))}
</Box>
);
}
render(<Dashboard />);
Ink is used in production by Gatsby, Prisma, and Shopify's CLI tools. Its React model means you can use hooks, context, and component composition -- skills that transfer directly from web development.
blessed and blessed-contrib
For more traditional TUI applications (think htop or midnight commander), blessed provides a curses-like API with windows, forms, tables, and charts. blessed-contrib adds dashboard widgets like line charts, bar graphs, maps, and sparklines.
import blessed from 'blessed';
import contrib from 'blessed-contrib';
const screen = blessed.screen({ smartCSR: true });
const grid = new contrib.grid({ rows: 2, cols: 2, screen });
const line = grid.set(0, 0, 1, 1, contrib.line, {
label: 'Requests/sec',
showLegend: true,
});
const log = grid.set(1, 0, 1, 2, contrib.log, {
label: 'Server Log',
});
screen.key(['q', 'C-c'], () => process.exit(0));
screen.render();
While blessed hasn't been actively maintained, it remains the most feature-complete TUI framework for Node.js and is still widely used.
Graceful Cancellation with Ctrl+C
Users will press Ctrl+C. Your CLI must handle this gracefully -- cleaning up temporary files, killing child processes, and restoring terminal state.
The @clack/prompts approach
Clack's isCancel() utility makes this declarative:
import { text, isCancel, cancel } from '@clack/prompts';
const name = await text({ message: 'Project name:' });
if (isCancel(name)) {
cancel('Operation cancelled.');
process.exit(0);
}
Global cleanup with process signals
For broader cleanup, listen to process signals:
const cleanup = () => {
// Remove temp directories
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
// Kill child processes
childProcesses.forEach(cp => cp.kill());
// Restore terminal
process.stdout.write('\x1B[?25h'); // Show cursor
console.log('\nCleaned up. Goodbye.');
};
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
process.on('uncaughtException', (err) => { cleanup(); throw err; });
Always restore the cursor (\x1B[?25h). Some prompt libraries hide it during rendering and if the process exits abnormally, users are left with an invisible cursor -- a frustrating experience.
Accessibility Considerations
Terminal accessibility is often overlooked, but screen reader users do use CLIs. Some guidelines:
Don't rely solely on color. Use prefixes like
[ERROR],[WARN],[OK]alongside color coding. Thechalklibrary'sleveloption lets you detect color support and degrade gracefully.Provide non-interactive fallbacks. Detect when stdin isn't a TTY (piped input, CI environments) and accept the same options as flags:
const isTTY = process.stdin.isTTY;
const name = isTTY
? await text({ message: 'Project name:' })
: process.env.PROJECT_NAME || 'my-app';
Use semantic structure. Screen readers parse terminal output linearly. Keep output structured with consistent indentation and clear section headers.
Announce state changes. When a spinner completes, the success/failure message should be self-contained. Don't rely on the animation itself to communicate progress.
Support
--no-interactiveflags. Many CI systems setCI=true. Detect this and skip prompts entirely, using defaults or environment variables.
const isCI = process.env.CI === 'true' || process.env.CI === '1';
if (isCI && !allFlagsProvided) {
console.error('Missing required flags in non-interactive mode. Run with --help.');
process.exit(1);
}
Putting It All Together: A Project Scaffolder
Let's build a complete CLI that combines everything we've covered. This scaffolder creates a new project with the user's chosen framework, features, and configuration.
#!/usr/bin/env node
import { intro, outro, text, select, multiselect, confirm, spinner, isCancel, cancel, note } from '@clack/prompts';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import color from 'picocolors';
// Handle non-interactive mode
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: create-project [options]
Options:
--name <name> Project name
--template <name> Template (react, vue, svelte)
--typescript Enable TypeScript
--no-interactive Skip prompts, use defaults
-h, --help Show this help
`);
process.exit(0);
}
intro(color.bgCyan(color.black(' create-project ')));
const project = {};
// Step 1: Project name
project.name = await text({
message: 'What is your project name?',
placeholder: 'my-app',
validate: (value) => {
if (!value) return 'Project name is required.';
if (!/^[a-z0-9-_]+$/.test(value)) return 'Use lowercase letters, numbers, hyphens, or underscores.';
if (fs.existsSync(value)) return `Directory "${value}" already exists.`;
},
});
if (isCancel(project.name)) { cancel('Cancelled.'); process.exit(0); }
// Step 2: Template
project.template = await select({
message: 'Pick a project template:',
options: [
{ value: 'react', label: 'React', hint: 'with Vite' },
{ value: 'vue', label: 'Vue', hint: 'with Vite' },
{ value: 'svelte', label: 'Svelte', hint: 'with SvelteKit' },
{ value: 'node', label: 'Node.js', hint: 'plain Node.js project' },
],
});
if (isCancel(project.template)) { cancel('Cancelled.'); process.exit(0); }
// Step 3: TypeScript
project.typescript = await confirm({ message: 'Use TypeScript?', initialValue: true });
if (isCancel(project.typescript)) { cancel('Cancelled.'); process.exit(0); }
// Step 4: Features (conditional on project type)
if (project.template !== 'node') {
project.features = await multiselect({
message: 'Select features:',
options: [
{ value: 'eslint', label: 'ESLint', hint: 'code linting' },
{ value: 'prettier', label: 'Prettier', hint: 'code formatting' },
{ value: 'vitest', label: 'Vitest', hint: 'unit testing' },
{ value: 'husky', label: 'Husky', hint: 'git hooks' },
],
required: false,
});
if (isCancel(project.features)) { cancel('Cancelled.'); process.exit(0); }
}
// Step 5: Git init
project.git = await confirm({ message: 'Initialize git repository?', initialValue: true });
if (isCancel(project.git)) { cancel('Cancelled.'); process.exit(0); }
// Summary
const lang = project.typescript ? 'TypeScript' : 'JavaScript';
note(
`Name: ${project.name}\nTemplate: ${project.template}\nLanguage: ${lang}\nFeatures: ${(project.features || []).join(', ') || 'none'}\nGit: ${project.git ? 'yes' : 'no'}`,
'Project Summary'
);
// Scaffold
const s = spinner();
s.start('Creating project structure...');
fs.mkdirSync(project.name, { recursive: true });
// ... copy template files based on selections ...
s.stop('Project structure created.');
s.start('Installing dependencies...');
execSync('npm install', { cwd: project.name, stdio: 'ignore' });
s.stop('Dependencies installed.');
if (project.git) {
s.start('Initializing git...');
execSync('git init && git add -A && git commit -m "Initial commit"', {
cwd: project.name,
stdio: 'ignore',
});
s.stop('Git repository initialized.');
}
outro(color.green(`Done! cd ${project.name} and start building.`));
This scaffolder demonstrates the full lifecycle: intro/outro framing, sequential prompts with cancellation, conditional branching, validation, a summary step for confirmation, and spinners for long operations.
Wrapping Up
The gap between a script that works and a CLI that people enjoy using is interactive design. The Node.js ecosystem provides everything you need:
- @clack/prompts or Inquirer for guided input collection
- ora or Clack's built-in spinner for progress indication
- cli-progress for determinate progress bars
- Ink for full React-powered terminal UIs
- Proper signal handling for graceful cleanup
Start with @clack/prompts for your next CLI. It takes five minutes to replace raw process.argv parsing with guided prompts, and your users will thank you for it. The best CLI tools feel like a conversation, not an exam -- and with these libraries, building that experience is straightforward.