nanospinner
Nanospinner is a small Node.js terminal status spinner for command-line programs. You create one spinner, start it while an asynchronous job runs, then replace the animation with a success, error, warning, info, or custom final line. It ships CommonJS code plus TypeScript declarations and depends only on picocolors. Its deliberately narrow API covers one active spinner, custom frames, colors, timing, text updates, and output streams, without the task orchestration or multi-spinner display found in larger CLI libraries.
A good fit for one simple spinner when tiny scope matters and its signal and TTY choices match your CLI. Install Ora or a task runner when output coordination, multiple tasks, or finer terminal behavior matters more than minimalism.
Use it if
- You need one compact status spinner around a command-line task and do not need nested or concurrent task rendering
- Your project consumes CommonJS, or uses Node ESM interoperability, and wants bundled TypeScript declarations
- You want to choose custom frames, timing, color, text, and the output stream without bringing in a full prompt framework
- You want non-TTY runs to produce a single plain status line instead of an animation loop
- You need multiple simultaneous spinners: multi-spinner support is still an unchecked roadmap item in the README, and the API models only one spinner instance at a time
- You need a mature task runner with nested tasks, persistence, prefixes, indentation, or stream-safe logging; the published types expose only spinner drawing and status methods, so Ora or Listr2 is a better fit
- Your CLI must preserve its own signal policy: start() registers SIGINT and SIGTERM handlers, and those handlers call process.exit with 130 or 143 while the spinner is active
- You route UI output to stderr while stdout is redirected and still expect animation: version 1.2.2 decides TTY capability from file descriptor 1 even though its default output stream is stderr
- You need active feature development or a large maintainer pool: the repository has one npm maintainer, its last source push was December 2024, and the public roadmap contains only an unfinished multi-spinner item
Setup reality
Installation is just npm install nanospinner. There are no peer dependencies, native extensions, credentials, or configuration files; picocolors is the sole runtime dependency, and the package includes declaration files. The surprises are runtime behavior rather than installation. Output defaults to process.stderr, but version 1.2.2 computes its TTY flag once from stdout file descriptor 1. Redirecting stdout can therefore disable animation even when stderr is attached to a terminal, and selecting process.stdout or another stream does not recalculate that decision. In a non-TTY or CI process, start() renders one hyphen-prefixed line and does not schedule more frames, which is useful for logs but may not match snapshot expectations. While active, the spinner adds SIGINT and SIGTERM listeners that stop it and call process.exit; terminal applications with their own shutdown flow need to stop the spinner before taking over signal handling. Choose the stream when calling createSpinner because update() accepts a stream in its TypeScript option shape but the 1.2.2 implementation does not apply that field. Also note that clear() directly writes ANSI cursor controls even outside a TTY, reset() cancels the timer but does not remove signal listeners, and calling stop() with no argument clears the animation without printing a final message. Prefer success(), error(), warn(), info(), or stop('message') on every completion path so the timer and signal listeners are cleaned up.
Patterns
Wrap one asynchronous taskshow-basic-spinner
import { createSpinner } from 'nanospinner'
const spinner = createSpinner('Downloading release').start()
await downloadRelease()
spinner.success({ text: 'Release downloaded' })Call a finishing method on every path. It clears the timer, removes the package's signal listeners, restores the cursor in a TTY, and prints the final line.
Finish with success or errorreport-task-failure
const spinner = createSpinner('Publishing').start()
try {
await publish()
spinner.success('Published')
} catch (error) {
spinner.error('Publish failed')
throw error
}error() only changes the terminal status. It does not throw, set process.exitCode, or preserve the caught error for you.
Change progress text while runningupdate-spinner-text
const spinner = createSpinner('Reading manifest').start()
const manifest = await readManifest()
spinner.update(`Installing ${manifest.dependencies.length} packages`)
await installDependencies(manifest)
spinner.success('Dependencies installed')update() changes future renders. Pass the output stream to createSpinner instead; version 1.2.2 ignores the stream field when supplied to update().
Use custom frames and timingcustomize-animation
const spinner = createSpinner('Waiting for worker', {
frames: ['.', '..', '...'],
interval: 120,
color: 'cyan',
}).start()
await waitForWorker()
spinner.success('Worker ready')An empty frames array falls back to the built-in frames, and an interval of 0 falls back to 50 ms because the implementation uses truthy defaults.
Write spinner output to stdoutchoose-output-stream
const spinner = createSpinner('Generating report', {
stream: process.stdout,
}).start()
await generateReport()
spinner.success('Report generated')The default stream is stderr. TTY capability is still calculated globally from stdout when the module loads, regardless of the stream selected here.
Show a warning resultfinish-with-warning
const spinner = createSpinner('Checking configuration').start()
const missing = await findOptionalSettings()
if (missing.length) {
spinner.warn(`Missing optional settings: ${missing.join(', ')}`)
} else {
spinner.success('Configuration complete')
}warn() stops the spinner and uses the built-in yellow warning symbol; it does not write to a separate warning channel.
End with an informational statusfinish-with-info
const spinner = createSpinner('Looking for updates').start()
const update = await findUpdate()
update
? spinner.info(`Version ${update.version} is available`)
: spinner.success('Already up to date')info() is a terminal presentation method, not a logger. The final line goes to the spinner's configured stream.
Stop with a custom mark and coloruse-custom-final-mark
const spinner = createSpinner('Skipping cached files').start()
await inspectCache()
spinner.stop({
text: 'Cache is current',
mark: '=',
color: 'gray',
})stop() with no argument clears the active output but prints no final line. Pass a string or object when the result should remain visible.
Avoid finishing an inactive spinnercheck-spinner-state
const spinner = createSpinner('Connecting').start()
try {
await connect()
if (spinner.isSpinning()) spinner.success('Connected')
} finally {
if (spinner.isSpinning()) spinner.stop('Connection cancelled')
}isSpinning() reflects the package's internal flag. It becomes false after stop, success, error, warn, info, or reset.
Reuse a completed spinnerreuse-spinner-instance
const spinner = createSpinner('Step one').start()
await stepOne()
spinner.success('Step one done')
spinner.reset().start('Step two')
await stepTwo()
spinner.success('Step two done')Finish the first run before reset. reset() clears the timer but does not itself remove SIGINT and SIGTERM listeners installed by start().
Load nanospinner from CommonJSuse-commonjs
const { createSpinner } = require('nanospinner')
const spinner = createSpinner('Building').start()
build().then(
() => spinner.success('Built'),
error => {
spinner.error('Build failed')
throw error
},
)The published entry point is CommonJS. ESM named imports work through Node interoperability, while require() is the package's native module shape.
Skip spinner creation in machine-readable modedisable-spinner-explicitly
const quiet = process.env.CI || process.argv.includes('--json')
const spinner = quiet ? null : createSpinner('Loading').start()
const data = await loadData()
spinner?.success('Loaded')
if (process.argv.includes('--json')) console.log(JSON.stringify(data))Nanospinner has no enabled option. It emits one plain line outside a TTY, so bypass it explicitly when stdout must contain only JSON or another machine-readable format.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ora | npm | Choose it for a fuller spinner API, established terminal edge-case handling, prefixes, indentation, and broader presentation controls |
| yocto-spinner | npm | Choose it when you want another tiny single-spinner package with a newer release line and an ESM-first API |
| cli-spinners | npm | Choose it when you only need a catalog of frame sequences and will own the rendering loop yourself |