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.jsonUsage
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
- Two-mode arc. When
valueis set, the ring renders a static percentage. WhendurationMsis set, a drift-freesetTimeoutloop drives the ring from full to empty —valueis ignored. The arc is one SVG<circle>withstroke-dasharray = Cand an animatedstroke-dashoffset(C = 2πr). - Motion-value driven. A
useMotionValueholds 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. setTimeout, notsetInterval. Each tick schedules the next one and reads the actual wall-clock delta viaperformance.now(). This avoids the drift that browsers introduce when they coalesce interval timers in background tabs.- Auto-pause on tab hide. A
document.visibilitychangelistener pauses the countdown when the page is hidden during a run; it resumes on return unless the countdown completed in the meantime. - Tone-driven palette. Five semantic tones. The track always uses
stroke-cb-borderat 40% opacity so the ring reads softly until there's progress to show.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Completion percentage in [0, 100]. Ignored when durationMs is set. |
durationMs | number | — | When set, the ring becomes a countdown. |
running | boolean | — | Controlled run state. Pair with onRunningChange. |
defaultRunning | boolean | false | Uncontrolled initial run state. |
onRunningChange | (running: boolean) => void | — | Fires 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). |
showLabel | boolean | true | Render the centered label. |
format | (msRemainingOrPercent: number) => string | mm:ss (countdown) or 42% (percent) | Format the centered label. |
onComplete | () => void | — | Fires once when the countdown first reaches zero. |
transition | Transition | SPRINGS.smooth | Spring transition for the arc. |
className | string | — | Merged onto the root <div> via cn(). |
Accessibility
- Renders with
role="progressbar"in percentage mode androle="timer"in countdown mode. - Always exposes
aria-valuenow,aria-valuemin={0},aria-valuemax={100}rounded to integer percent. - Countdown mode additionally sets
aria-labelto 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: reducesnaps the arc to its target with no spring.
Credits
- Extracted from:
terminal-dreams(src/components/cookbook/TimerRing.tsx). Generalized: stripped the project-specificTimerdata 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/uncontrolledrunning, drift-free tick loop, auto-pause on tab-hide) mirroring theTimercompanion.