Beautiful CLI Loading States with ora, listr2, and cli-progress
Nobody likes staring at a frozen terminal. When your CLI tool fetches data, processes files, or runs builds, users need visual feedback that something is actually happening. A silent terminal breeds anxiety — did it crash? Is it stuck? Should I Ctrl+C?
Three libraries solve this problem elegantly: ora for spinners, listr2 for task lists, and cli-progress for progress bars. This article shows you how to use each one, combine them for complex workflows, and handle edge cases like piped output and nested tasks.
ora: Elegant Terminal Spinners
ora is the go-to library for terminal spinners. It's minimal, beautiful, and handles all the terminal escape code complexity for you.
Basic Usage
import ora from 'ora';
const spinner = ora('Fetching user data...').start();
try {
const data = await fetchUsers();
spinner.succeed(`Loaded ${data.length} users`);
} catch (err) {
spinner.fail('Could not fetch users');
process.exit(1);
}
That's it. The spinner animates while your async work runs, then resolves to a green checkmark or red cross. The API is intentionally tiny — .start(), .stop(), .succeed(), .fail(), .warn(), .info().
Customizing Spinners
ora ships with dozens of spinner styles via the cli-spinners package. You can pick one by name or define your own:
// Use a built-in style
const spinner = ora({
text: 'Deploying...',
spinner: 'dots12',
color: 'cyan',
});
// Or define a custom animation
const spinner = ora({
text: 'Uploading...',
spinner: {
interval: 100,
frames: ['◐', '◓', '◑', '◒'],
},
});
The interval controls frame speed in milliseconds. Lower values spin faster. The frames array defines each animation frame — ora cycles through them sequentially.
Custom Spinner Animations
Building your own spinners is where things get creative. You can use any Unicode characters to craft animations that match your tool's personality:
const rocket = {
interval: 120,
frames: [
'🚀 ',
' 🚀 ',
' 🚀 ',
' 🚀 ',
' 🚀 ',
' 🚀 ',
' 🚀 ',
' 🚀',
],
};
const braille = {
interval: 80,
frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
};
ora({ text: 'Launching...', spinner: rocket }).start();
You can also update the spinner text mid-flight, which is useful for showing progress context:
const spinner = ora('Starting build...').start();
spinner.text = 'Compiling TypeScript...';
await compileTS();
spinner.text = 'Bundling assets...';
await bundle();
spinner.text = 'Generating sourcemaps...';
await sourcemaps();
spinner.succeed('Build complete in 4.2s');
Prefixed and Indented Spinners
ora supports a prefixText option for labeling parallel operations:
const api = ora({ prefixText: '[API]', text: 'Connecting...' }).start();
const db = ora({ prefixText: '[DB] ', text: 'Migrating...' }).start();
This renders cleanly when you have multiple concurrent operations reporting to the same terminal.
listr2: Task Lists with Structure
When your CLI runs multiple steps, a spinner alone is not enough. listr2 renders structured task lists with status indicators, supporting sequential execution, parallel execution, and nested groups.
Sequential Tasks
import { Listr } from 'listr2';
const tasks = new Listr([
{
title: 'Install dependencies',
task: async () => {
await execa('npm', ['install']);
},
},
{
title: 'Run database migrations',
task: async () => {
await runMigrations();
},
},
{
title: 'Seed test data',
task: async () => {
await seedDatabase();
},
},
]);
await tasks.run();
Each task displays a spinner while running, a checkmark on success, and a cross on failure. Failed tasks halt the sequence by default.
Parallel Execution
Pass concurrent: true to run tasks in parallel:
const tasks = new Listr(
[
{
title: 'Lint code',
task: async () => await execa('eslint', ['.']),
},
{
title: 'Type check',
task: async () => await execa('tsc', ['--noEmit']),
},
{
title: 'Run tests',
task: async () => await execa('vitest', ['run']),
},
],
{ concurrent: true }
);
All three tasks run simultaneously, each with its own spinner. The list completes when every task finishes.
Nested Task Groups
listr2 supports nesting, which is powerful for breaking complex workflows into logical groups:
const tasks = new Listr([
{
title: 'Backend',
task: (ctx, task) =>
task.newListr(
[
{
title: 'Compile server',
task: async () => await buildServer(),
},
{
title: 'Generate API docs',
task: async () => await generateDocs(),
},
],
{ concurrent: true }
),
},
{
title: 'Frontend',
task: (ctx, task) =>
task.newListr([
{
title: 'Bundle JavaScript',
task: async () => await bundleJS(),
},
{
title: 'Optimize images',
task: async () => await optimizeImages(),
},
{
title: 'Purge unused CSS',
task: async () => await purgeCSS(),
},
]),
},
]);
The nested lists render indented beneath their parent. The parent task shows its own spinner until all children complete. You can mix concurrent: true and sequential execution at different nesting levels — compile the backend in parallel while the frontend steps run sequentially.
Sharing Context Between Tasks
listr2 passes a context object through the entire task chain:
const tasks = new Listr([
{
title: 'Discover files',
task: async (ctx) => {
ctx.files = await glob('src/**/*.ts');
},
},
{
title: 'Process files',
task: async (ctx) => {
for (const file of ctx.files) {
await processFile(file);
}
},
},
]);
await tasks.run(); // context is created automatically
cli-progress: Progress Bars for Measurable Work
When you know the total amount of work — files to process, bytes to download, records to import — a progress bar is more informative than a spinner. cli-progress renders customizable progress bars with ETA, speed, and percentage.
Basic Progress Bar
import { SingleBar, Presets } from 'cli-progress';
const bar = new SingleBar({}, Presets.shades_classic);
const files = await glob('data/**/*.json');
bar.start(files.length, 0);
for (const file of files) {
await processFile(file);
bar.increment();
}
bar.stop();
This renders something like:
████████████████░░░░░░░░░░░░░░ 52% | ETA: 3s | 156/300
Custom Format Strings
cli-progress uses format tokens you can arrange however you want:
const bar = new SingleBar({
format: '{bar} | {percentage}% | {value}/{total} files | ETA: {eta}s',
barCompleteChar: '█',
barIncompleteChar: '░',
barsize: 40,
hideCursor: true,
});
Available tokens include {bar}, {percentage}, {total}, {value}, {eta}, {duration}, and {eta_formatted}. You can also pass custom payload values:
bar.update({ filename: currentFile });
// Then reference it in format:
format: '{bar} | {filename}';
Multiple Progress Bars
For parallel operations, use MultiBar:
import { MultiBar, Presets } from 'cli-progress';
const multi = new MultiBar(
{ clearOnComplete: false },
Presets.shades_grey
);
const downloadBar = multi.create(totalBytes, 0, { task: 'Download' });
const extractBar = multi.create(totalFiles, 0, { task: 'Extract' });
// Update each independently
downloadBar.increment(chunkSize);
extractBar.increment();
// When all done
multi.stop();
Combining All Three for Complex Workflows
Real CLI tools often need all three patterns. Here's a deployment pipeline that uses listr2 for structure, ora for indeterminate waits, and cli-progress for file uploads:
import { Listr } from 'listr2';
import ora from 'ora';
import { SingleBar, Presets } from 'cli-progress';
const deploy = new Listr([
{
title: 'Build project',
task: (ctx, task) =>
task.newListr(
[
{ title: 'TypeScript', task: async () => await compile() },
{ title: 'Assets', task: async () => await bundleAssets() },
],
{ concurrent: true }
),
},
{
title: 'Upload artifacts',
task: async (ctx) => {
const files = await glob('dist/**/*');
const bar = new SingleBar(
{ format: ' Uploading | {bar} | {percentage}% | {value}/{total}' },
Presets.shades_classic
);
bar.start(files.length, 0);
for (const file of files) {
await upload(file);
bar.increment();
}
bar.stop();
},
},
{
title: 'Verify deployment',
task: async () => {
const spinner = ora('Waiting for health check...').start();
await waitForHealthy('https://api.example.com/health');
spinner.succeed('Service is healthy');
},
},
]);
await deploy.run();
The build step runs subtasks in parallel with spinners. The upload step shows measurable progress with a bar. The verification step uses ora for an indeterminate wait. Each tool handles what it does best.
Graceful Handling When Piped (No TTY)
All three libraries need special consideration when stdout is not a terminal — for example, when your CLI output is piped to a file or another command:
import ora from 'ora';
// ora handles this automatically — it falls back to static text
const spinner = ora({
text: 'Working...',
isSilent: !process.stderr.isTTY,
}).start();
For cli-progress, disable the bar when there's no TTY:
if (process.stdout.isTTY) {
bar.start(total, 0);
// ... increment as usual
bar.stop();
} else {
// Fall back to periodic log lines
console.log(`Processing ${total} files...`);
}
listr2 has a built-in fallback renderer:
const tasks = new Listr(taskDefinitions, {
renderer: process.stdout.isTTY ? 'default' : 'simple',
});
The 'simple' renderer outputs plain text log lines instead of animated spinners, which works cleanly in CI environments, log files, and piped output.
A good pattern is to centralize this decision:
const isTTY = process.stdout.isTTY;
export const ui = {
spinner: (text) => ora({ text, isSilent: !isTTY }),
tasks: (items, opts) =>
new Listr(items, {
...opts,
renderer: isTTY ? 'default' : 'simple',
}),
progress: (total) => {
if (!isTTY) return { increment: () => {}, stop: () => {} };
const bar = new SingleBar({}, Presets.shades_classic);
bar.start(total, 0);
return bar;
},
};
This keeps your business logic clean — call ui.spinner('Loading...') everywhere and the TTY handling is automatic.
Wrapping Up
Each of these libraries solves a distinct problem:
- ora for indeterminate waits — network requests, compilation, anything where you don't know how long it will take.
- cli-progress for measurable work — file processing, downloads, batch operations where you know the total count.
- listr2 for structured multi-step workflows — build pipelines, deployment scripts, setup wizards with sequential and parallel steps.
Used together, they transform a silent, anxiety-inducing CLI into something that feels responsive and polished. The key is matching the right indicator to the right type of work: spinners for unknowns, bars for countables, task lists for structure. And always remember to degrade gracefully when there's no TTY — your CI pipeline will thank you.
Top comments (0)