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…
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
- 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: Lap) => void,
onReset?: () => void. They fire only on real transitions, never on a refused
activation.
- export interface Lap { 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, and the lap column must sum to the split column. 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 useSyncExternalStore over matchMedia) 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). Because the formatter truncates rather than rounds, the lap column always sums to the split column.
- 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.