Skip to main content
Technology & EngineeringCli Development212 lines

Ora Spinners

Add progress indicators to CLI tools using ora and nanospinner for async operation feedback

Quick Summary20 lines
You are an expert in adding progress indicators to CLI applications using ora and nanospinner.

## Key Points

- Use `spinner.succeed()`, `.fail()`, `.warn()`, and `.info()` to terminate spinners with clear final states rather than just calling `.stop()`, which leaves no visual indicator of the outcome.
- Update `spinner.text` during long operations to show progress — a static "Loading..." for 30 seconds feels broken, but "Loading... (142/500 files)" communicates progress.
- Choose nanospinner for tools where dependency count matters (e.g., `create-*` scaffolders that users install globally) and ora for feature-rich applications.
- Ora v7+ is ESM-only — use `ora@6` if your project requires CommonJS, or migrate to `"type": "module"` in `package.json`.

## Quick Example

```bash
# Full-featured
npm install ora

# Lightweight alternative
npm install nanospinner
```
skilldb get cli-development-skills/Ora SpinnersFull skill: 212 lines
Paste into your CLAUDE.md or agent config

Progress Indicators (ora, nanospinner) — CLI Development

You are an expert in adding progress indicators to CLI applications using ora and nanospinner.

Overview

Progress indicators give users feedback during long-running operations. Ora is the standard choice with rich customization (spinner styles, colors, prefixes). Nanospinner is a lightweight alternative at 1/14th the size. Both show an animated spinner with a status message that can be updated, succeeded, or failed.

Setup & Configuration

Install your preferred library:

# Full-featured
npm install ora

# Lightweight alternative
npm install nanospinner

Basic ora usage:

import ora from 'ora';

const spinner = ora('Loading configuration').start();

try {
  const config = await loadConfig();
  spinner.succeed('Configuration loaded');
} catch (err) {
  spinner.fail('Failed to load configuration');
  process.exit(1);
}

Basic nanospinner usage:

import { createSpinner } from 'nanospinner';

const spinner = createSpinner('Loading configuration').start();

try {
  const config = await loadConfig();
  spinner.success({ text: 'Configuration loaded' });
} catch (err) {
  spinner.error({ text: 'Failed to load configuration' });
  process.exit(1);
}

Core Patterns

Sequential operations with status updates

import ora from 'ora';

async function deploy(env: string) {
  const spinner = ora({ text: 'Starting deployment', color: 'cyan' }).start();

  spinner.text = 'Running tests';
  await runTests();

  spinner.text = 'Building application';
  await build();

  spinner.text = `Deploying to ${env}`;
  await deployTo(env);

  spinner.text = 'Running health checks';
  await healthCheck();

  spinner.succeed(`Deployed to ${env} successfully`);
}

Multiple independent spinners

import ora from 'ora';

async function parallelTasks() {
  const tasks = [
    { name: 'Database migration', fn: runMigrations },
    { name: 'Asset compilation', fn: compileAssets },
    { name: 'Cache warmup', fn: warmCache },
  ];

  // Run sequentially with individual spinners
  for (const task of tasks) {
    const spinner = ora(task.name).start();
    try {
      await task.fn();
      spinner.succeed();
    } catch (err) {
      spinner.fail(`${task.name}: ${err.message}`);
    }
  }
}

Custom spinner styles

import ora from 'ora';

// Use a different built-in spinner
const spinner = ora({
  text: 'Processing',
  spinner: 'dots12',    // dots, line, star, hamburger, bouncingBar, etc.
  color: 'magenta',
  prefixText: '[build]',
}).start();

// Warning state (yellow, special symbol)
spinner.warn('Completed with warnings');

// Info state
spinner.info('Using cached results');

Wrapping a promise

import ora from 'ora';

// ora can wrap a promise directly
const result = await ora.promise(fetchData(), {
  text: 'Fetching data',
  successText: 'Data fetched',
  failText: 'Failed to fetch data',
});

Non-TTY fallback

import ora from 'ora';

// ora automatically degrades in non-TTY (pipes, CI)
// It logs the text without animation
// You can also detect and handle manually:
const spinner = ora({
  text: 'Processing',
  isSilent: !process.stderr.isTTY,
}).start();

Progress with percentage

import ora from 'ora';

const spinner = ora('Downloading').start();

async function downloadWithProgress(url: string) {
  const response = await fetch(url);
  const total = Number(response.headers.get('content-length'));
  let downloaded = 0;

  const reader = response.body.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    downloaded += value.length;
    const pct = Math.round((downloaded / total) * 100);
    spinner.text = `Downloading... ${pct}%`;
  }

  spinner.succeed('Download complete');
}

Best Practices

  • Use spinner.succeed(), .fail(), .warn(), and .info() to terminate spinners with clear final states rather than just calling .stop(), which leaves no visual indicator of the outcome.
  • Update spinner.text during long operations to show progress — a static "Loading..." for 30 seconds feels broken, but "Loading... (142/500 files)" communicates progress.
  • Choose nanospinner for tools where dependency count matters (e.g., create-* scaffolders that users install globally) and ora for feature-rich applications.

Core Philosophy

Progress indicators exist to maintain user trust during operations they cannot observe. A spinning indicator says "I am working" — and updating its text to say "Connecting to database..." or "Processing 142/500 files..." transforms vague trust into informed confidence. The difference between a good CLI and a frustrating one is often just the quality of its progress feedback.

Always terminate spinners with an explicit outcome. Calling .stop() leaves a blank line that tells the user nothing. Calling .succeed(), .fail(), .warn(), or .info() leaves a permanent line in the terminal with a clear visual indicator of what happened. Users scrolling through their terminal history should be able to reconstruct the sequence of events from the spinner outcomes alone.

Choose the right weight for your context. Ora is feature-rich and appropriate for application-level CLIs where you want custom spinner styles, color control, and promise wrapping. Nanospinner is 14x smaller and appropriate for scaffolding tools, libraries, and anything distributed via npx where install size matters. Both achieve the same core goal — the difference is in the dependency footprint.

Anti-Patterns

  • Leaving spinners running after completion — forgetting to call .succeed() or .fail() leaves an animated spinner on screen indefinitely, confusing users about whether the operation is still in progress.

  • Showing a static message for long operations — displaying "Loading..." for 30 seconds with no progress updates feels broken; update spinner.text periodically to show what is happening and how far along it is.

  • Writing to stdout while a spinner is activeconsole.log calls during spinner animation cause visual glitching and garbled output; use spinner.info() to log a line cleanly or clear/re-render the spinner around log statements.

  • Using ora in non-TTY environments without fallback — spinners in CI logs or piped output produce streams of ANSI escape codes; detect non-TTY with process.stderr.isTTY and fall back to simple log lines.

  • Nesting spinners — running multiple concurrent ora instances produces overlapping animations and cursor conflicts; use sequential spinners or a multi-line progress display like listr2 for parallel tasks.

Common Pitfalls

  • Writing to stdout with console.log while a spinner is active causes visual glitching — use spinner.clear() before logging, then spinner.render() to resume, or use ora's built-in spinner.info() to log a line cleanly.
  • Ora v7+ is ESM-only — use ora@6 if your project requires CommonJS, or migrate to "type": "module" in package.json.

Install this skill directly: skilldb add cli-development-skills

Get CLI access →