Skip to content

Spinner

import { spinner } from "@mongez/copper";
const sp = spinner({ text: "Compiling…" }).start();
try {
await build();
sp.succeed("Build complete");
} catch (err) {
sp.fail("Build failed");
throw err;
}

Options

OptionDefaultNote
text""Trailing message; can be changed live via .update(...)
framessymbols.spinner (Braille on modern terms, ASCII on legacy cmd.exe)Array of strings, cycled
interval80Milliseconds between frames
color"cyan"Any ColorName
streamprocess.stdoutPass process.stderr to keep progress on stderr

Handle methods

MethodBehavior
start(text?)Begin animating; if already running, no-op
update(text)Replace the trailing text without restarting
stop()Clear the line and stop the interval
succeed(text?)Stop, write green ✔ <text> + newline
fail(text?)Stop, write red ✖ <text> + newline
warn(text?)Stop, write yellow ⚠ <text> + newline
info(text?)Stop, write cyan ℹ <text> + newline
.isSpinningBoolean reflecting interval status

Non-TTY fallback

When stream.isTTY is false (CI, piped output, capture buffers), the spinner does not animate. Instead:

  • .start() prints the text once with a newline.
  • Finalizers (succeed/fail/warn/info) print their colored marker + final text on a single line.

This means logs in CI stay readable line-by-line — no cursor jiggling, no carriage-return artifacts.

Awaiting work

async function withSpinner<T>(text: string, run: () => Promise<T>): Promise<T> {
const sp = spinner({ text }).start();
try {
const result = await run();
sp.succeed();
return result;
} catch (err) {
sp.fail();
throw err;
}
}

The spinner’s interval is unref’d, so a forgotten .stop() won’t keep your Node process alive. Still — always pair start() with a finalizer in the same try/finally block.