DEV Community

Wilson Xu
Wilson Xu

Posted on

Beautiful CLI Loading States with ora, listr2, and cli-progress

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);
}
Enter fullscreen mode Exit fullscreen mode

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: ['', '', '', ''],
  },
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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(),
        },
      ]),
  },
]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

This renders something like:

████████████████░░░░░░░░░░░░░░  52% | ETA: 3s | 156/300
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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}';
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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...`);
}
Enter fullscreen mode Exit fullscreen mode

listr2 has a built-in fallback renderer:

const tasks = new Listr(taskDefinitions, {
  renderer: process.stdout.isTTY ? 'default' : 'simple',
});
Enter fullscreen mode Exit fullscreen mode

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;
  },
};
Enter fullscreen mode Exit fullscreen mode

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)