Skip to main content

Guide

ora vs nanospinner vs cli-spinners 2026

Compare ora, nanospinner, and cli-spinners for Node.js terminal spinners. Promise support, bundle size tradeoffs, CI non-TTY detection, and best practices.

·PkgPulse Team·
0

TL;DR

ora (~20M weekly downloads) is the most feature-complete terminal spinner — elegant promise API, 80+ animation styles from cli-spinners, chalk-powered colors, and automatic non-TTY detection. nanospinner (~8M weekly downloads) is the zero-dependency ultra-light alternative at roughly 1KB, offering the same core start/update/done API with no spinner style variety. cli-spinners (~15M weekly downloads) is pure animation data — 80+ spinner definitions with no rendering logic whatsoever, used internally by ora and useful when you're building a custom renderer. For most CLI tools in 2026, the choice is between ora and nanospinner: ora when you need promise wrapping or multiple spinner styles, nanospinner when bundle size and zero dependencies are the priority.

Quick Comparison

orananospinnercli-spinners
Weekly downloads~20M~8M~15M
Bundle size~15 KB~1 KB~3 KB
Dependencies5+00
Renders spinnersYesYesNo (data only)
Spinner styles80+180+ (data)
Promise wrapperYesNoN/A
Text updatesYesYesN/A
Non-TTY detectionYesYesN/A
Colorschalkbuilt-inN/A

Why CLI Spinners Matter

The difference between a CLI tool that feels polished and one that feels amateurish often comes down to terminal feedback. When running npm install, the spinner and progress indicators transform a process that takes 30–60 seconds into something that feels active rather than frozen. The visual confirmation that something is happening prevents the most common user behavior when a CLI goes quiet: killing the process and running it again, which is almost never helpful and sometimes actively harmful.

For developer tools that run daily — build scripts, deployment pipelines, database migration runners, code generation tools — good terminal UX reduces cognitive load. A developer running deploy prod needs to know that the deployment is progressing, not wonder if their terminal has hung. Spinners provide that confidence signal at essentially zero cost, and the absence of them is noticed even when their presence isn't.

The spinner ecosystem in Node.js settled on ora as the de facto standard early, driven by sindresorhus's prolific package curation and ora's clean API that handles the most common patterns well. nanospinner emerged as a compelling alternative for projects where zero dependencies and minimal size matter. cli-spinners underpins both by supplying the raw animation data that actual spinners render.


ora

ora is the most widely used terminal spinner in the Node.js ecosystem. Its API is intentionally minimal — start a spinner, optionally update the text, then call one of the terminal state methods (succeed, fail, warn, info) to end it. The entire lifecycle of "something is loading" maps cleanly to a handful of method calls with no ceremony.

What separates ora from simpler spinners is the breadth of its configuration surface. It pulls in cli-spinners to expose 80+ animation styles, uses chalk for a full range of colors, and provides first-class ora.promise() which wraps any promise with a spinner that starts automatically and calls succeed or fail based on the settlement. This promise wrapper covers the most common async CLI pattern without any explicit start/stop management.

Basic usage

import ora from "ora"

const spinner = ora("Loading packages...").start()

await fetchPackages()

spinner.succeed("Loaded 1,234 packages")
// ✔ Loaded 1,234 packages

Text updates

const spinner = ora("Fetching data...").start()

spinner.text = "Processing 500 packages..."
await processFirst500()

spinner.text = "Processing remaining packages..."
await processRemaining()

spinner.succeed("All 1,234 packages processed")

Promise integration

import ora from "ora"

// Wraps a promise — starts spinner, resolves = succeed, rejects = fail:
const packages = await ora.promise(
  fetchPackages(),
  { text: "Fetching packages..." }
)

// Or with custom success/fail text:
await ora.promise(deployToProduction(), {
  text: "Deploying to production...",
  successText: "Deployed successfully!",
  failText: "Deployment failed",
})

The ora.promise() method is particularly useful in sequential build scripts where each step wraps a single async operation. Without it, you'd need to manually call spinner.start() before each await and wrap every async call in a try/catch to call spinner.fail() on error. With ora.promise(), that error handling is implicit.

Spinner styles

import ora from "ora"

// Default (dots):
ora({ text: "Loading...", spinner: "dots" }).start()
// ⠋ Loading...

// Other styles:
ora({ text: "Loading...", spinner: "line" }).start()
// - Loading...

ora({ text: "Loading...", spinner: "bouncingBar" }).start()
// [    ▖] Loading...

ora({ text: "Loading...", spinner: "earth" }).start()
// 🌍 Loading...

// Custom frames:
ora({
  text: "Building...",
  spinner: {
    interval: 100,
    frames: ["◐", "◓", "◑", "◒"],
  },
}).start()

Colors and prefixes

import ora from "ora"

const spinner = ora({
  text: "Installing dependencies...",
  color: "cyan",
  prefixText: "[npm]",
}).start()

// Status methods:
spinner.succeed("Dependencies installed")  // ✔ (green)
spinner.fail("Installation failed")         // ✖ (red)
spinner.warn("Peer dependency warnings")    // ⚠ (yellow)
spinner.info("Using cached packages")       // ℹ (blue)
spinner.stop()                              // Stop without symbol
spinner.clear()                             // Clear spinner line

Non-TTY handling

import ora from "ora"

// ora auto-detects non-interactive environments:
// In CI/pipes: just prints text, no animation
// In TTY: full animated spinner

const spinner = ora({
  text: "Building...",
  isSilent: false,       // Set true to suppress all output
  discardStdin: true,    // Don't buffer stdin while spinning
}).start()

// Force non-spinner mode:
const spinner2 = ora({
  text: "Building...",
  isEnabled: false,  // Disable spinner animation
}).start()

nanospinner

nanospinner was built with a single guiding constraint: zero dependencies and minimal footprint. The result is a roughly 1KB package that covers the 80% use case — start a spinner, update the text, end with success or error — without pulling in chalk, cli-spinners, or any other runtime dependency.

The API mirrors ora closely enough that most developers can switch without consulting documentation. createSpinner(text).start() vs ora(text).start(). The completion methods differ slightly in signature — nanospinner takes an options object (spinner.success({ text: "Done" })) where ora takes a string argument — but the conceptual model is identical.

Where nanospinner deliberately cuts scope: there is only one built-in spinner animation style, there is no promise() wrapper, and there are no prefix/suffix text options. For teams building internal CLI tooling where the spinner is a minor implementation detail, these tradeoffs are sensible. For teams publishing a polished CLI tool where spinner customization matters, they are not.

Basic usage

import { createSpinner } from "nanospinner"

const spinner = createSpinner("Loading packages...").start()

await fetchPackages()

spinner.success({ text: "Loaded 1,234 packages" })
// ✔ Loaded 1,234 packages

Status methods

import { createSpinner } from "nanospinner"

const spinner = createSpinner("Processing...").start()

// Update text:
spinner.update({ text: "Processing 500 of 1,234..." })

// Final status:
spinner.success({ text: "Done!" })  // ✔ Done!
spinner.error({ text: "Failed!" })   // ✖ Failed!
spinner.warn({ text: "Warning!" })   // ⚠ Warning!
spinner.stop()                        // Stop without symbol
spinner.clear()                       // Clear line
spinner.reset()                       // Reset to initial state

Custom mark and color

import { createSpinner } from "nanospinner"

const spinner = createSpinner("Deploying...", {
  color: "cyan",
  mark: "🚀",  // Custom success mark
}).start()

await deploy()
spinner.success()  // 🚀 Deploying...

The zero-dependency argument

The case for nanospinner is strongest when you're publishing a CLI tool through npm. Every dependency you add is a dependency your users download at install time. ora's dependency tree includes cli-spinners for animation data and chalk for terminal colors — both are small, well-maintained packages, but they exist as separate entries in node_modules. For an internal build script that developers run locally, this distinction is irrelevant. For a published tool that thousands of developers install, it matters.

The more compelling argument for zero-dependency packages is philosophical: many CLI authors in 2026 prefer tools that "do one thing" without pulling in secondary packages for functionality they may not even use. If your CLI tool already imports chalk for other purposes, ora's chalk dependency adds nothing. But if chalk isn't otherwise needed, choosing nanospinner avoids adding chalk as an implicit transitive dependency solely for spinner color support.


cli-spinners

cli-spinners is not a spinner library — it is a spinner data library. It exports an object containing 80+ animation definitions, where each definition is an { interval: number, frames: string[] } pair. There is no rendering logic, no TTY detection, no chalk integration. The frames are just strings. You bring your own rendering loop.

This means cli-spinners downloads (~15M weekly) are overwhelmingly driven by tools that depend on it transitively, primarily ora. The realistic use case for importing cli-spinners directly is building a custom spinner renderer with your own output management — for example, a multi-line spinner display that renders several concurrent tasks, or a spinner embedded in a custom log formatter that needs to control cursor movement explicitly.

What it provides

import spinners from "cli-spinners"

// Just data — no rendering:
console.log(spinners.dots)
// { interval: 80, frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] }

console.log(spinners.line)
// { interval: 130, frames: ["-", "\\", "|", "/"] }

console.log(spinners.bouncingBar)
// { interval: 80, frames: ["[    ]", "[   =]", "[  ==]", "[ ===]", ...] }

// Available spinners:
console.log(Object.keys(spinners).length)
// → 80+ different spinner animations

Build your own spinner

import spinners from "cli-spinners"

function createSimpleSpinner(text: string, style = "dots") {
  const { frames, interval } = spinners[style]
  let i = 0
  let timer: NodeJS.Timeout

  return {
    start() {
      timer = setInterval(() => {
        process.stdout.write(`\r${frames[i++ % frames.length]} ${text}`)
      }, interval)
    },
    stop(finalText?: string) {
      clearInterval(timer)
      process.stdout.write(`\r✔ ${finalText ?? text}\n`)
    },
  }
}

// Usage:
const spinner = createSimpleSpinner("Building...", "earth")
spinner.start()
await build()
spinner.stop("Built successfully!")

Available spinner styles

Popular styles from cli-spinners:
  dots      ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
  line      -\|/
  star      ✶✸✹✺✹✷
  earth     🌍🌎🌏
  moon      🌑🌒🌓🌔🌕🌖🌗🌘
  runner    🚶🏃
  arrow     ←↖↑↗→↘↓↙
  clock     🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛
  bounce    ⠁⠂⠄⠂
  arc       ◜◠◝◞◡◟
  toggle    ⊶⊷

  See full list: https://jsfiddle.net/sindresorhus/2eLtsbey/embedded/result/

Practical Patterns

Sequential tasks

The most common spinner pattern in CLI tools is a sequence of named steps, each with its own spinner that succeeds or fails before the next begins. ora handles this cleanly with a single spinner instance that you reuse across steps:

import ora from "ora"

async function buildProject() {
  const spinner = ora()

  spinner.start("Linting...")
  await lint()
  spinner.succeed("Lint passed")

  spinner.start("Running tests...")
  await test()
  spinner.succeed("Tests passed (42 tests)")

  spinner.start("Building...")
  await build()
  spinner.succeed("Built in 2.3s")

  spinner.start("Deploying...")
  await deploy()
  spinner.succeed("Deployed to production")
}

With @clack/prompts

If your CLI tool requires user interaction alongside progress indicators, @clack/prompts provides a cohesive spinner implementation that integrates with its prompt components. For pure spinner needs ora is sufficient, but for mixed interactive/loading workflows, the unified @clack/prompts experience is worth considering:

import * as p from "@clack/prompts"

// @clack/prompts has its own spinner — better for interactive CLIs:
const s = p.spinner()

s.start("Installing dependencies...")
await installDeps()
s.stop("Dependencies installed")

s.start("Building project...")
await build()
s.stop("Built successfully")

Conditional spinners (CI detection)

When your CLI tool runs in both interactive terminals and CI environments, you want the spinner in interactive mode and plain timestamped logs in CI. Both ora and nanospinner handle this automatically, but if you want explicit control over the fallback behavior, a conditional logger abstraction is a clean pattern:

import ora from "ora"

function createLogger() {
  const isCI = process.env.CI || !process.stdout.isTTY

  if (isCI) {
    // In CI — just log text, no spinners:
    return {
      start: (text: string) => console.log(`→ ${text}`),
      succeed: (text: string) => console.log(`✓ ${text}`),
      fail: (text: string) => console.error(`✗ ${text}`),
    }
  }

  // Interactive terminal — use ora:
  const spinner = ora()
  return {
    start: (text: string) => spinner.start(text),
    succeed: (text: string) => spinner.succeed(text),
    fail: (text: string) => spinner.fail(text),
  }
}

Testing CLI Spinners

Testing code that uses terminal spinners requires a different approach than testing pure logic. The spinner itself — the animation, the ANSI codes, the TTY detection — is not what you're typically testing. You're testing the business logic that runs while the spinner is active, and the final output state: did the operation succeed or fail, and was the right text displayed?

The most practical approach is to inject the spinner as a dependency rather than importing ora or nanospinner directly in your command implementations. By accepting a spinner-shaped interface (start, succeed, fail, stop) rather than a concrete ora instance, you can pass a no-op spy in tests without any TTY mocking:

// types.ts
interface Spinner {
  start(text?: string): void
  succeed(text?: string): void
  fail(text?: string): void
  stop(): void
}

// command.ts
async function deployCommand(spinner: Spinner) {
  spinner.start("Deploying to production...")
  try {
    await deploy()
    spinner.succeed("Deployed successfully")
  } catch (err) {
    spinner.fail(`Deployment failed: ${err.message}`)
    process.exit(1)
  }
}

// command.test.ts
test("deploy command shows success on successful deploy", async () => {
  const spinner = { start: vi.fn(), succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() }
  vi.mocked(deploy).mockResolvedValue(undefined)

  await deployCommand(spinner)

  expect(spinner.start).toHaveBeenCalledWith("Deploying to production...")
  expect(spinner.succeed).toHaveBeenCalledWith("Deployed successfully")
  expect(spinner.fail).not.toHaveBeenCalled()
})

This approach is more maintainable than trying to test the terminal output directly. The spinner is a side effect that provides feedback to the user; your tests should verify the logical outcomes, not the presentation layer. If you do need to test TTY output specifically — for example, in an integration test that validates the full CLI output — process the output through strip-ansi first to remove escape codes before asserting on the visible text.


CI/CD Environments and Non-TTY Detection

Spinners are fundamentally terminal-specific features. They work by writing ANSI escape sequences to stdout that overwrite the current line with each animation frame. This technique assumes stdout is a real TTY — a terminal emulator that understands ANSI codes. In CI environments like GitHub Actions, CircleCI, and Jenkins, stdout is typically piped to a log aggregator or redirected to a file. ANSI escape codes produce garbled output or simply don't render, and overwriting the current line produces a flood of separate log lines rather than a clean animation.

Both ora and nanospinner handle this automatically. When process.stdout.isTTY is false or undefined, they detect the non-interactive environment and switch to plain text output: each status update prints as a new line, without ANSI codes and without line overwriting. The same CLI code that produces a smooth animated spinner in an interactive terminal produces clean, log-friendly plain text in CI. You do not need different code paths, environment checks, or conditional spinner instantiation.

The plain text fallback in CI is actually more useful for CI logs than animation would be. Animated spinners provide no persistent record of what happened — each frame overwrites the last. The plain text fallback prints each significant state transition on its own line, creating a timestamped audit trail in the CI log output. This is the behavior you want: verbose enough to diagnose failures, clean enough to read quickly.

Test your CLI in both modes before shipping. Run it with | cat to force non-TTY mode and verify the output is informative. Many teams discover after deploying their CLI that CI logs are unreadable because the spinner's plain-text mode only prints the initial "Loading..." text and never prints completion — a bug that never surfaced during local development where animation ran correctly.


Bundle Size Matters for CLI Distribution

When distributing a CLI tool through npm, bundle size affects two things: the time it takes for users to install your tool, and the cold start latency when the tool runs. A CLI that requires downloading a large node_modules tree creates friction before the user has even run it once.

nanospinner's footprint — approximately 1KB with zero dependencies — contrasts with ora's approximately 15KB footprint, which includes dependencies on chalk for colors and cli-spinners for animation data. In absolute terms, 15KB is trivially small. The argument for nanospinner isn't about bytes per se; it's about the dependency philosophy. Many CLI tool authors have moved toward "minimize dependencies unless they add significant value." For a spinner, the core value is start-update-done with sensible defaults. nanospinner delivers exactly that with no dependencies.

The bundle size argument is strongest when distributing as a compiled single-file binary using tools like esbuild, ncc, or pkg. In these cases, every kilobyte of source contributes to the final binary size. For an Electron-based desktop tool or a CLI distributed as a platform-native binary, ora's full feature set is worth the size — the binary is large regardless and users don't pay install-time costs. For lightweight npm-installable utilities targeting a minimal install footprint, nanospinner's zero-dependency design fits the ethos of keeping the tool lean.


Feature Comparison

Featureorananospinnercli-spinners
RenderingYesYesNo (data only)
Spinner styles80+180+ (data)
Promise wrapperYesNoNo
Text updatesYesYesN/A
Colorschalkbuilt-inN/A
Non-TTY detectionYesYesN/A
Dependencies5+00
Size~15 KB~1 KB~3 KB
Weekly downloads~20M~8M~15M

When to Use Each

Use ora if:

  • Building a polished CLI tool with multiple spinner styles
  • Need promise wrapping (ora.promise)
  • Want prefix/suffix text, indent, and custom symbols
  • The ~15 KB size and 5+ dependencies are acceptable

Use nanospinner if:

  • Want the smallest spinner library (~1 KB)
  • Simple spinner needs: start → update → done
  • Zero dependency preference is important
  • Building size-sensitive CLI tools or published utilities

Use cli-spinners if:

  • Building your own spinner renderer from scratch
  • Need the raw animation frame data
  • Custom rendering requirements: concurrent spinners, custom layout
  • Already using a different rendering library and just need animation data

CLI UX: Why Spinners Matter

The difference between a CLI tool that feels polished and one that feels amateurish often comes down to terminal feedback. When running npm install, the spinner and progress indicators transform a process that takes 30-60 seconds into something that feels active rather than frozen. The visual confirmation that something is happening prevents the most common user behavior when a CLI goes quiet: killing the process and running it again, which is almost never helpful and sometimes harmful.

For developer tools that run daily — build scripts, deployment pipelines, database migration runners, code generation tools — good terminal UX reduces cognitive load in a meaningful way. A developer running deploy prod needs to know that the deployment is progressing, not wonder if their terminal has hung. Spinners provide that confidence signal at essentially zero cost, and the absence of them is noticed even when their presence isn't.

The spinner ecosystem in Node.js settled on ora as the de facto standard early, driven by sindresorhus's prolific npm package curation and ora's clean, minimal API that handles the most common patterns well. For new CLI tools in 2026, the choice has simplified to two real options: ora for feature-richness, or nanospinner for minimal bundle size. cli-spinners is a data dependency that either library uses, and you'd reach for it directly only when building your own spinner renderer.

Both ora and nanospinner integrate cleanly with the broader interactive CLI ecosystem. They work alongside prompt libraries like @clack/prompts and inquirer for multi-step interactive tools, and alongside progress bar libraries like cli-progress for tools that need percentage-based tracking rather than indeterminate spinning. The choice between ora and nanospinner typically comes down to a single question: does your tool need ora.promise() or multiple spinner styles, or is the core start/update/done API enough?

Bundle Size Matters for CLI Distribution

When distributing a CLI tool through npm, bundle size affects two things: the time it takes for users to install your tool, and the cold start latency when the tool runs. A CLI that requires downloading 50MB of node_modules creates friction before the user has even run it once. A tool that starts in 50ms feels instant; one that takes 500ms to initialize trains users to avoid running it frequently.

nanospinner's footprint — approximately 1KB with zero dependencies — contrasts sharply with ora's approximately 15KB footprint, which includes dependencies on chalk for colors and cli-spinners for animation data. In absolute terms, 15KB is trivially small. The argument for nanospinner isn't about bytes per se; it's about the dependency philosophy. Many CLI tool authors in the sindresorhus ecosystem have moved toward "minimize dependencies unless they add significant value." For a spinner, the core value is start-update-done with sensible defaults. nanospinner delivers exactly that with no dependencies. If you need chalk for colors, you probably already have it in your CLI for other reasons; you're not importing it solely for the spinner.

The bundle size argument is strongest when distributing as a compiled single-file binary using tools like esbuild, ncc, or pkg. In these cases, every kilobyte of source that gets bundled contributes to the final binary size. For an Electron-based desktop tool or a CLI distributed as a platform-native binary, ora's full feature set is worth the size — the binary is large regardless and users don't pay install-time costs. For lightweight npm-installable utilities that target minimal install footprint, nanospinner's zero-dependency design fits the ethos of keeping the tool lean.

CI/CD Environments and Non-TTY Detection

Spinners are fundamentally terminal-specific features. They work by writing escape sequences to stdout that overwrite the current line with each animation frame. This technique assumes that stdout is a real TTY — a terminal emulator that understands ANSI escape codes. In CI/CD environments like GitHub Actions, CircleCI, and Jenkins, stdout is typically piped to a log aggregator or redirected to a file. ANSI escape codes in this context produce garbled output or simply don't render, and overwriting the current line produces a flood of separate log lines rather than a clean animation.

Both ora and nanospinner handle this automatically. When process.stdout.isTTY is false or undefined, they detect the non-interactive environment and switch to plain text output: each status update is printed as a new line, without ANSI codes and without line overwriting. The same CLI code that produces a smooth animated spinner in an interactive terminal produces clean, log-friendly plain text output in CI. You don't need different code paths, environment checks, or conditional spinner instantiation.

The plain text fallback in CI is actually more useful for CI logs than animation would be. Animated spinners provide no persistent record of what happened; each frame overwrites the last. The plain text fallback prints each significant state transition on its own line, creating a timestamped audit trail in the CI log output. This is the behavior you want: verbose enough to diagnose failures, clean enough to read quickly. Test your CLI in both modes before shipping — run it with | cat to force non-TTY mode and verify the output is informative.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on ora v8.x, nanospinner v1.x, and cli-spinners v3.x.

Compare CLI utilities and developer tooling on PkgPulse →

See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.