All specs
Interactive · spinner

Spinner

Not every wait is interesting. But every wait is visible, and every wait resolves into success or failure. A good spinner makes waiting feel like progress.

When to use

Any operation that takes longer than ~200ms and blocks the user

Wrap sync or async work; Caret's spinner shows while the work runs

Pair with prompt when the resolved value triggers a wait

Don't use a spinner for sub-200ms operations — it will flash and confuse the user.

---

Anatomy

Spinning:

⠋ Deploying to production

Resolved (success):

✓ Deployed to production · 3.4s

Resolved (failure):

✗ Deploy failed · 3.4s

Cancelled (SIGINT):

— cancelled · 1.2s

1. **Spinner frame** — Braille rotation (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) in accent, 10 frames, 80ms per frame

2. **Label** — fg.default, required, can be updated mid-run

3. **Suffix** — optional, fg.muted, default is the elapsed time (· 3.4s)

4. **Resolve symbol** — (success), (danger), (fg.subtle) on finalize

---

Variants

One. Just like error, the spinner is a single disciplined pattern. No fast/slow/fancy modes.

---

States

StateDescription
idleNot yet started
spinningActive; frame advancing every 80ms
successResolved as with final label
failureResolved as with final label
cancelledResolved as (user sent SIGINT during spin)

---

Micro-interactions

TriggerMotionDuration
Frame cycleBraille rotation through 10 frames80ms/frame, continuous
Label updateCrossfade from old label to new labelmotion.duration.quick
Success resolveLast frame morphs → , brief accent pulsemotion.duration.default
Failure resolveLast frame morphs → , brief danger pulsemotion.duration.default
Suffix reveal· 3.4s fades in after resolvemotion.duration.quick

The 80ms frame tempo is fixed. It does not scale with motion tokens — this is the rotation rhythm, not a micro-interaction.

Auto-disabled when non-TTY, CARET_REDUCED_MOTION=1, or narrow terminal:

No rotation. Label prints once on start.

Resolve prints the final / / line in place.

Elapsed time still logged in the suffix.

---

Tokens used

Color

accent — spinner frames, success resolve, success pulse

success — success pulse accent (optional, subtle)

danger — failure , failure pulse

fg.default — label

fg.muted — suffix (elapsed time)

fg.subtle — cancelled

Motion

Frame rhythm: 80ms (fixed)

Resolve pulse: motion.duration.default

Label crossfade: motion.duration.quick

Symbols

⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ — Braille rotation set

— success

— failure

— cancelled

---

Behavior

### Function wrapping (preferred form)

await spinner('Deploying', async () => {
  await deploy()
})

The spinner starts before the wrapped function runs and stops after it resolves or throws. On success, the label becomes ✓ Deploying · <elapsed>. On throw, it becomes ✗ Deploying · <elapsed> and the error propagates.

### Label updates mid-flight

await spinner('Preparing', async (s) => {
  await prepare()
  s.update('Uploading')
  await upload()
  s.update('Finalizing')
  await finalize()
})

Each s.update() crossfades the label. Elapsed time keeps ticking from the original start.

### Explicit success / failure labels

await spinner('Deploying', deployFn, {
  onSuccess: 'Deployed to production',
  onFailure: 'Deploy failed',
})

Without these options, the resolve line keeps the current label and only the symbol changes.

### notifyOnComplete — system notification

await spinner('Deploying', deployFn, {
  notifyOnComplete: true,
})

Default threshold: **10 seconds**. Below this, no notification fires.

Uses OS native notification via caret.notify. Never the terminal bell.

Suppressed when CARET_NO_NOTIFY=1 or the OS is in Do Not Disturb.

Silent fallback if the platform has no notification API.

Custom threshold:

await spinner('Migrating database', migrateFn, {
  notifyOnComplete: { threshold: 30_000 },
})

### SIGINT / cancellation

If the user hits ctrl+c while spinning:

The spinner resolves to — cancelled · <elapsed>

SIGINT propagates (never swallowed)

The wrapped async function is not given cancellation by default

For true cancellation, request an AbortSignal:

await spinner('Deploying', async ({ signal }) => {
  await deploy({ signal })
})

The signal aborts on SIGINT. Your wrapped function is responsible for honoring it.

### Non-TTY behavior

Prints label once on start (no frame rotation)

Prints the resolved line on end

Elapsed time still logged

No cursor manipulation — output is tee-safe, grep-safe, log-safe

### Manual control (rarely needed)

const s = spinner.start('Deploying')
try {
  await deploy()
  s.success('Deployed')
} catch (e) {
  s.fail('Deploy failed')
  throw e
}

Prefer the function-wrapping form. Manual control exists for cases where the async boundary doesn't map cleanly.

---

Accessibility

**NO_COLOR**: frames still cycle (they're geometric); label and resolve symbols carry all state

**Reduced motion**: no rotation, no morph, no pulse — label prints once, resolves with the final symbol

**Narrow terminal**: label truncates with ellipsis; suffix hides on very narrow widths

**Dumb terminal**: no Braille; falls back to | / - \ rotation or a plain ... indicator

**Screen reader**: start and resolve are announced as ARIA-like busyready transitions via stderr metadata

---

API

import { spinner } from './caret'

// wrap an async function (preferred)
const result = await spinner('Deploying', async () => {
  return await deploy()
})

// with label updates
await spinner('Preparing', async (s) => {
  await prepare()
  s.update('Uploading')
  await upload()
})

// with explicit success / failure labels
await spinner('Deploying', deployFn, {
  onSuccess: 'Deployed',
  onFailure: 'Deploy failed',
})

// with system notification on completion
await spinner('Migrating database', migrate, {
  notifyOnComplete: true,
})

// with AbortSignal for cancellation
await spinner('Fetching', async ({ signal }) => {
  return await fetch(url, { signal })
})

// manual control
const s = spinner.start('Deploying')
try {
  await deploy()
  s.success('Deployed')
} catch (e) {
  s.fail('Deploy failed')
  throw e
}
type SpinnerOptions = {
  onSuccess?: string
  onFailure?: string
  notifyOnComplete?: boolean | { threshold?: number }  // default threshold: 10_000ms
  suffixTime?: boolean                                  // default: true
}

---

Do & don't

Do

Prefer the function-wrapping form — it handles success, failure, elapsed time, and cancellation for you

Use s.update() when the work has distinct phases the user cares about

Use notifyOnComplete for anything that might run longer than a minute

Prefer one spinner with label updates over three spinners in sequence

Don't

Don't use a spinner for sub-200ms operations — they flash

Don't use a spinner as a progress bar — if you have a percentage, use progress (later)

Don't write your own \r + animation logic — Caret owns the render layer

Don't update the label every frame; crossfade once per phase, not every tick

Don't use spinner for background work the user didn't ask for — spinners imply "I'm waiting for this"

Don't customize the Braille set, the tempo, or the resolve symbols — they are the brand

---

Out of scope

**Percentage progress** — use progress component (later)

**Nested spinners** — v0 supports one active spinner at a time

**Custom frame sets** — Caret owns the Braille rotation

**Tempo customization** — 80ms per frame, fixed

**Inline spinner inside another component** — that's handled by the host component (e.g. Prompt's inline validation spinner)

**Spinner groups / task lists** — step component handles multi-phase flows, later