Stopwatch
A count-up stopwatch with start/pause/reset and lap recording, timed off a monotonic clock so laps stay exact and a throttled background tab never loses time.
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- badge, button, utils, use-prefers-reduced-motion
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stopwatch.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Stopwatch" component: a count-up timer
with start / pause / resume, reset, and lap recording. Dependencies:
lucide-react for icons, the shadcn Button and Badge primitives, and cn() from
@/lib/utils. No timing or animation library.
Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
registryDependencies) — usePrefersReducedMotion() decides whether the newest
lap row plays its entrance keyframe. If rebuilding without it: a
useSyncExternalStore over matchMedia("(prefers-reduced-motion: reduce)") with a
"change" listener and a server snapshot of false.
- Export a forwardRef <div> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onReset">
(onReset is a native form event on that interface and is being replaced).
- Props, all optional:
precision?: "seconds" | "tenths" | "centiseconds" | "milliseconds"
(default "centiseconds") — fractional digits 0/1/2/3.
showLaps?: boolean (default true) — paints the Lap button and the lap list.
autoStart?: boolean (default false) — running from mount.
defaultElapsedMs?: number (default 0) — resume a persisted session.
defaultLaps?: number[] — lap *durations* in ms, oldest first; splits are
derived by accumulation. Both defaults are read once, in a lazy useState
initializer, never on later renders.
label?: string (default "Stopwatch") — the group's accessible name.
onRunningChange?: (running: boolean) => void, onLap?: (lap: StopwatchLap) => void,
onReset?: () => void. They fire only on real transitions, never on a refused
activation.
- export interface StopwatchLap { index: number; lapMs: number; splitMs: number } —
index is 1-based and never reused before a reset, lapMs is this lap alone,
splitMs is the cumulative total at the moment it was recorded.
- Also export the formatter, so consumers can render an onLap payload with the
same digits the widget uses.
Behavior
- Timing model. Keep four refs: runningRef, baseRef (ms banked by finished
runs), startedAtRef (clock reading at the current run's start, null while
stopped) and lastSplitRef. The single source of truth is
readElapsed() = running ? base + (clock() - startedAt) : base.
- clock() is performance.now(), falling back to Date.now(). Two reasons, both
load-bearing: (a) performance.now() is monotonic, so an NTP correction, a DST
boundary or a user dragging the system clock cannot move time backwards
mid-run; (b) elapsed time is *read* from the clock and never accumulated tick
by tick — a timer callback is clamped to >= 1s and coalesced while the tab is
hidden, so anything that adds a fixed delta per tick silently loses minutes in
a background tab. Subtracting two clock reads is already correct on the first
frame after the tab wakes up. Because Date.now() is not monotonic, clamp lap
deltas at zero.
- Paint loop. One effect keyed on [running, step]: while running, a
requestAnimationFrame loop calls setElapsed(readElapsed()); the frame is
cancelled in the effect cleanup, so a pause, a reset and an unmount all stop
it. rAF, not setInterval: it is display-synced, and a hidden tab suspends it
entirely instead of queueing a backlog of stale callbacks.
- Quantized repaint. step = 10 ** (3 - digits). The state setter bails out when
Math.floor(prev / step) === Math.floor(next / step), returning prev so React
skips the re-render — at precision="seconds" that is one repaint per second
instead of sixty.
- Start reads and writes runningRef synchronously and returns early if it is
already true; a fast double click or Space landing on both the button and the
widget would otherwise re-stamp startedAt and drop the time in between.
- Pause reads readElapsed() *before* flipping runningRef (afterwards it returns
the banked value), banks it in baseRef, nulls startedAtRef and mirrors it into
state so the frozen readout matches the banked total exactly.
- Lap refuses unless the watch is running. Otherwise: read the clock once, take
previous = lastSplitRef, write lastSplitRef = split *synchronously before any
setState*, bump a lapCountRef for the index, and append
{ index, lapMs: max(0, split - previous), splitMs: split }. Two clicks inside
one frame then produce two honest sub-millisecond laps instead of two copies
of one stale value.
- Reset refuses when there is nothing to zero (stopped, base 0, no laps);
otherwise it stops, zeroes base / lastSplit / lapCount, empties the list and
fires onReset (plus onRunningChange(false) only if it really was running).
- Fastest / slowest are computed only when there are at least two laps AND the
min differs from the max: one lap is neither, and a field of identical laps
has no outlier. Ties mark every row that holds the extreme value.
- Formatting: truncate, never round — the readout must not show time that has
not happened yet. Laps and splits come from fractional clock reads and are
truncated separately, so the lap column can trail the split column by up to
one display step per lap (two 1005.4 ms laps show 00:01.00 + 00:01.00 against
a 00:02.01 split). Split
total ms into h = floor(t / 3600000), m = floor(t / 60000) % 60,
s = floor(t / 1000) % 60, fraction = floor((t % 1000) / 10 ** (3 - digits)).
Render "MM:SS.ff" and only prepend "H:" once there is an hour; the hours
segment then grows without a wrap.
- Degenerate cases: defaultElapsedMs below the sum of defaultLaps is raised to
that sum; negative inputs clamp to 0; the in-progress lap (elapsed minus the
last split) can be negative for a single frame right after a lap, and the
formatter's max(0, …) absorbs it.
- Keyboard. The root is a focusable role="group" (tabIndex 0) carrying
aria-keyshortcuts. Space or Enter toggles run/pause, L records a lap, R
resets. Chain the consumer's onKeyDown first and bail on defaultPrevented; bail
on any modifier key; bail on event.repeat so a held key cannot spam
zero-length laps; bail out of Space/Enter when the event target is inside a
button / link / field (that node owns its own activation keys, and handling it
here as well would start and stop the watch in one keystroke); bail out of the
letter shortcuts inside a text field or contenteditable. preventDefault on
Space so the page does not scroll.
- ARIA. Root role="group" + aria-label. The readout is role="timer" with
aria-live="off": timer is a live region, and a value changing sixty times a
second must never be announced — assistive tech reads it on demand. Inside it
the digits are aria-hidden and an sr-only span carries a spoken form
("1 minute 23 seconds elapsed"). A separate sr-only role="status" announces
transitions only: started / resumed / paused at X / lap N recorded, lap time
X / reset to zero. The lap list is a real <ol>/<li>.
- Refusals use aria-disabled plus the guard already in the handler — never the
native disabled attribute, which blurs the element the instant it flips and
would dump focus on <body> exactly when a reader pauses the watch while
standing on Lap. Do not add pointer-events-none either: the button stays
hoverable, focusable and announced. While refused, the aria-label is
overridden with the reason, keeping the visible word first ("Lap (unavailable
while the watch is stopped)").
- Cleanup: cancelAnimationFrame in the effect cleanup and the matchMedia
listener removed by useSyncExternalStore's own unsubscribe. No other timers,
listeners or observers exist.
Rendering & styling
- Semantic tokens only: rounded-xl border bg-card text-card-foreground shell,
bg-muted / text-muted-foreground for secondary text and the zebra rows
(odd:bg-muted/50), bg-primary for the running dot, text-primary + a
border-primary/40 outline Badge for the fastest lap, a destructive Badge for
the slowest, ring-ring for every focus ring. No hex, rgb or oklch anywhere.
- The readout is font-mono, text-4xl, font-semibold and tabular-nums so digits
never change width as they roll; lap and split columns are tabular-nums with a
min-w-[8ch] right-aligned box so the columns stay in line.
- Status row: a size-2 dot (bg-primary with animate-pulse while running, a static
muted dot when paused, a fainter one at zero) plus the word Running / Paused /
Ready — colour and motion are never the only signal.
- Newest lap on top: reverse a copy of the list for rendering so DOM order
matches visual order.
- Motion: one hoisted @keyframes (React 19 <style href precedence>) fades the
newest lap row in from -0.4em. It is skipped under prefers-reduced-motion
(read with the shared usePrefersReducedMotion hook) and skipped for rows that
came from defaultLaps, since those are already on screen at first paint. The
pulse carries motion-reduce:animate-none. Nothing about timing, laps or
keyboard depends on motion.
- A <kbd> hint row spells the shortcuts out for sighted users and is
aria-hidden, because aria-keyshortcuts already carries them.
- Merge the consumer className with cn() and spread the remaining props on the
root, after role/aria so both stay overridable.
Customization levers
- Precision is the density lever: "seconds" for a session timer, "centiseconds"
for sport, "milliseconds" for benchmarking. It changes the repaint rate too,
so prefer the coarsest one your surface actually reads.
- showLaps={false} strips the widget down to readout + two buttons; drop the
Reset button as well and it becomes a pure elapsed display.
- Persistence: bank readElapsed() and the lap durations on unmount (or on
pause), then feed them back as defaultElapsedMs / defaultLaps to resume a
session across a reload.
- Long sessions: the lap list is deliberately unbounded. Wrap it in a scroll
container, or slice it to the newest N rows, when the surface is height
constrained.
- Tokens: bg-card + border is the panel look; drop them for a bare readout in a
toolbar, or swap the fastest/slowest Badges for chart tokens
(var(--chart-2) / var(--chart-4)) if the rest of your page reads laps as data
rather than as status.
- Shortcut scope: tabIndex={0} on the root adds one tab stop. Set it to -1 and
call focus() from your own global hotkey if you would rather own the
scope — the handler is unchanged.
- Countdown mode is NOT a lever: counting toward a known deadline is a
different contract (a target instant, a completion callback) — use a
countdown component instead of teaching this one to run backwards.Concepts
- Read the clock, never accumulate ticks — elapsed time is always
base + (now − startedAt), so a frame that never fired costs nothing; a component that added a fixed delta per tick would lose real minutes the moment the tab was throttled, and the error would be permanent. - Monotonic source —
performance.now()cannot move backwards when the OS syncs its clock or crosses a DST boundary, which is exactly the failure aDate.now()-based stopwatch shows as a negative lap. - Lap vs split — each row carries two numbers: the lap (this segment alone) and the split (cumulative at that instant). The formatter truncates rather than rounds, so neither column shows time that has not happened yet; because each value is truncated on its own, the laps can add up to one display step per lap less than the split.
- Synchronous ref guard — every mutation reads and writes its refs inside the handler before any
setState, so a double click can neither re-stamp the start time nor record the same lap twice from a stale render value. - Refusal, not disablement — Lap while stopped and Reset at zero stay focusable and announced via
aria-disabledplus a guard; the nativedisabledattribute would blur the button the instant the watch paused and drop the reader on<body>. - Quantized repaint — the display step (
10 ** (3 − digits)) decides when a new value is worth a render, which is what letsprecision="seconds"re-render once a second while the underlying timing stays millisecond-exact.
Activity Feed
A grouped activity stream — consecutive entries that share a verb and a target fold into one line, with day separators, an unread boundary and four data states.
Calendar Month
A month grid of events — multi-day bars packed into shared lanes, a "+N more" day peek, today and out-of-month cells distinguished, configurable week start and four data states.