Timer Ring

A circular progress indicator. Two modes: pass value for a static percentage arc, or pass durationMs for a countdown that ticks every ~250ms and auto-pauses when the tab is hidden.

Customize
Mode
Value
60
Duration
30s
Tone
accent
Size
md
State

Installation

npx shadcn@latest add https://craftbits.dev/r/timer-ring.json

Usage

Percentage mode:

import { TimerRing } from "@craft-bits/core";
 
<TimerRing value={60} tone="accent" />

Countdown mode:

<TimerRing
  durationMs={30_000}
  defaultRunning
  tone="success"
  onComplete={() => playChime()}
/>

Custom label:

<TimerRing
  durationMs={10_000}
  format={(ms) => `${Math.ceil(ms / 1000)}s`}
  defaultRunning
/>

Understanding the component

  1. Two-mode arc. When value is set, the ring renders a static percentage. When durationMs is set, a drift-free setTimeout loop drives the ring from full to empty — value is ignored. The arc is one SVG <circle> with stroke-dasharray = C and an animated stroke-dashoffset (C = 2πr).
  2. Motion-value driven. A useMotionValue holds the current dashoffset. On every change, animate(mv, target, SPRINGS.smooth) springs the offset toward the new target. React doesn't re-render every frame — the spring drives the DOM directly.
  3. setTimeout, not setInterval. Each tick schedules the next one and reads the actual wall-clock delta via performance.now(). This avoids the drift that browsers introduce when they coalesce interval timers in background tabs.
  4. Auto-pause on tab hide. A document.visibilitychange listener pauses the countdown when the page is hidden during a run; it resumes on return unless the countdown completed in the meantime.
  5. Tone-driven palette. Five semantic tones. The track always uses stroke-cb-border at 40% opacity so the ring reads softly until there's progress to show.

Props

PropTypeDefaultDescription
valuenumberCompletion percentage in [0, 100]. Ignored when durationMs is set.
durationMsnumberWhen set, the ring becomes a countdown.
runningbooleanControlled run state. Pair with onRunningChange.
defaultRunningbooleanfalseUncontrolled initial run state.
onRunningChange(running: boolean) => voidFires when the clock starts, pauses, auto-pauses, or completes.
tone'default' | 'accent' | 'success' | 'warning' | 'error''accent'Semantic color for the arc.
size'sm' | 'md' | 'lg''md'SVG geometry (64 / 112 / 160 px).
showLabelbooleantrueRender the centered label.
format(msRemainingOrPercent: number) => stringmm:ss (countdown) or 42% (percent)Format the centered label.
onComplete() => voidFires once when the countdown first reaches zero.
transitionTransitionSPRINGS.smoothSpring transition for the arc.
classNamestringMerged onto the root <div> via cn().

Accessibility

  • Renders with role="progressbar" in percentage mode and role="timer" in countdown mode.
  • Always exposes aria-valuenow, aria-valuemin={0}, aria-valuemax={100} rounded to integer percent.
  • Countdown mode additionally sets aria-label to the current remaining time on every update.
  • The SVG and the centered label are both aria-hidden="true" — assistive tech reads only the ARIA values + label.
  • The component itself is non-interactive. Wrap it with your own buttons for pause/resume/reset.
  • prefers-reduced-motion: reduce snaps the arc to its target with no spring.

Credits

  • Extracted from: terminal-dreams (src/components/cookbook/TimerRing.tsx). Generalized: stripped the project-specific Timer data model, state-dependent glow / pulse / checkmark choreography, and the dynamic SVG filter id; added a percentage mode so the ring is useful outside countdowns; added headless countdown plumbing (controlled/uncontrolled running, drift-free tick loop, auto-pause on tab-hide) mirroring the Timer companion.