ora
Ora renders an animated spinner in the terminal while your Node CLI does async work. You create an instance with a message, call .start(), and end it with .succeed(), .fail(), .warn(), or .info(), which swap the spinner for a colored symbol and keep the final line in the scrollback. It picks sensible spinners per platform, writes to stderr by default so stdout stays pipeable, disables itself in CI and non-TTY contexts, and swallows stray keystrokes so typing does not shred the animation. oraPromise() wraps a whole promise in that lifecycle in one call.
The default spinner for Node CLIs, and the edge-case handling (CI detection, stdin discarding, stderr default, log-while-spinning) is what you are actually paying the eight dependencies for. Skip it only if you need multiple spinners, a progress bar, or CommonJS.
Use it if
- Your CLI has waits longer than a second (network calls, installs, builds) and you want users to see progress plus a persisted success or failure line
- You want TTY, CI, and piped-output detection handled for you: ora degrades to plain text automatically instead of dumping escape codes into logs
- You wrap promises a lot: oraPromise(action, {successText, failText}) covers the start/succeed/fail dance in one call
- You are already in the sindresorhus dependency universe (chalk, log-symbols, cli-spinners), so ora adds little new surface
- You need multiple concurrent spinners: ora is explicitly single-spinner by design, and the README itself points you at listr2 or spinnies for task lists
- You need a progress bar with percentages or ETA rather than an indeterminate spinner: that is cli-progress territory, ora cannot do it
- You are on CommonJS or Node below 20: ora is ESM only and v9 requires Node >=20, so require('ora') fails and older runtimes pin you to ora 5
- Your work is CPU-bound synchronous code: the event loop is blocked so the spinner freezes, and no spinner library fixes that without a worker thread
- You count install weight for a tiny tool: ora pulls in eight dependencies (chalk, cli-cursor, cli-spinners, string-width and friends); the same author ships yocto-spinner for exactly this case
Setup reality
npm install ora and one import is genuinely all of it: no config, types bundled, no native builds. The friction is environmental. Pure ESM since v6 means CommonJS projects get ERR_REQUIRE_ESM and either migrate or stay on ora 5. The default discardStdin puts stdin into raw mode, so Ctrl+C stops generating SIGINT from the terminal; ora re-emits it, but if you block the event loop with synchronous work the Ctrl+C is delayed until the work finishes. Windows terminals mostly fall back to the plain line spinner because Unicode detection there is best-effort, and spinners do not animate at all in Worker threads or non-interactive environments, which regularly surprises people testing in CI.
Patterns
Start a spinner and update it livebasic-spinner
import ora from 'ora';
const spinner = ora('Loading unicorns').start();
setTimeout(() => {
spinner.color = 'yellow';
spinner.text = 'Loading rainbows';
}, 1000);Pure ESM since v6: require('ora') throws ERR_REQUIRE_ESM. text, color, spinner, and indent are live setters, so you mutate the running instance instead of restarting it.
End with a persisted symbol linesucceed-fail-outcomes
import ora from 'ora';
const spinner = ora('Deploying').start();
try {
await deploy();
spinner.succeed('Deployed');
} catch (error) {
spinner.fail(`Deploy failed: ${error.message}`);
process.exitCode = 1;
}succeed/fail/warn/info stop the spinner and persist a green check, red cross, yellow warning, or blue info symbol; .stop() just clears the line and persists nothing.
Wrap a promise with oraPromisewrap-promise
import {oraPromise} from 'ora';
const user = await oraPromise(fetchUser(id), {
text: 'Fetching user',
successText: result => `Fetched ${result.name}`,
failText: error => `Fetch failed: ${error.message}`
});Resolves or rejects with the original promise result, calling succeed/fail for you. successText and failText accept functions that receive the result or error.
Pick a named spinner or define framescustom-spinner-frames
import ora from 'ora';
const named = ora({text: 'Thinking', spinner: 'moon'}).start();
const custom = ora({
text: 'Working',
spinner: {frames: ['-', '+', '-'], interval: 80}
});Names come from cli-spinners. On Windows outside Windows Terminal ora falls back to the line spinner because Unicode detection is unreliable; set spinner explicitly if you know the terminal can handle it.
Log messages while the spinner runslog-while-spinning
import ora from 'ora';
const spinner = ora('Processing...').start();
console.log('Step 1 complete');
console.error('warning: cache miss');
spinner.succeed('Done!');Ora intercepts writes to the same stream, clears itself, prints your line, and re-renders below. This works for both stdout and stderr; you do not need a special log method.
Persist a custom symbol and textstop-and-persist
import ora from 'ora';
const spinner = ora('Downloading').start();
spinner.stopAndPersist({
symbol: '#',
text: 'Download queued'
});Use this when none of succeed/fail/warn/info fits; the default symbol is a single space, which visually aligns the persisted text with symbol lines above it.
Send the spinner to stdoutstdout-instead-of-stderr
import ora from 'ora';
const spinner = ora({
text: 'Building',
stream: process.stdout
}).start();Default is stderr, which keeps stdout clean for pipeable program output. Only switch if the spinner itself is the product; enabled-detection then follows stdout's TTY state instead.
Silence output for tests and quiet modesilence-in-tests
import ora from 'ora';
const spinner = ora({
text: 'Syncing',
isSilent: process.env.NODE_ENV === 'test'
}).start();
spinner.succeed();isSilent suppresses everything including the persisted final line. isEnabled: false is different: it still prints the text as plain lines, just without animation and colors.
Add dynamic prefix or suffix textprefix-suffix-text
import ora from 'ora';
let done = 0;
const spinner = ora({
text: 'Fetching pages',
suffixText: () => `[${done}/10]`
}).start();
// later, as work completes
done += 1;prefixText and suffixText accept functions re-evaluated each frame, which is the cheap way to get a live counter without a progress bar library.
Control stdin discarding and Ctrl+C behaviorctrl-c-and-stdin
import ora from 'ora';
const spinner = ora({
text: 'Long task',
discardStdin: false
}).start();Default discardStdin: true puts stdin into raw mode so Enter presses do not break the animation, but Ctrl+C is then re-emitted by ora instead of arriving as terminal SIGINT; blocking the event loop delays it. Disable it if your CLI reads stdin or you see interrupt weirdness.
Drive one spinner from a worker threadworker-thread-control
// main.js
import {Worker} from 'node:worker_threads';
import ora from 'ora';
const spinner = ora().start();
const worker = new Worker('./worker.js');
worker.on('message', message => {
if (message.type === 'ora:text') spinner.text = message.text;
if (message.type === 'ora:succeed') spinner.succeed(message.text);
});Spinners do not animate inside Worker threads because they are not interactive; keep ora in the main thread and message it, which also keeps Ctrl+C responsive during CPU work.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| yocto-spinner | npm | You want the same start/succeed/fail idea in a much smaller zero-frills package; ora's own README suggests it. |
| listr2 | npm | You have several tasks and want a live task list with nested spinners, output collapsing, and error states per task. |
| nanospinner | npm | You want one tiny dependency with a similar chainable API and do not need ora's detection edge cases. |
| cli-progress | npm | Your work has measurable progress and users deserve a real bar with percentage and ETA instead of a spinner. |