Display

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.

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
badge, button, utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { Flag, Pause, Play, RotateCcw } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

/** The newest lap row settles in from above. Hoisted via React 19's <style href>. */
const LAP_KEYFRAMES = `@keyframes zy-lap-in{from{opacity:0;transform:translateY(-0.4em)}to{opacity:1;transform:translateY(0)}}`

/** Fractional digits per precision. Also the repaint quantum: 10 ** (3 - digits) ms. */
const DIGITS = { centiseconds: 2, milliseconds: 3, seconds: 0, tenths: 1 } as const

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/stopwatch.json

Prompt

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 a Date.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-disabled plus a guard; the native disabled attribute 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 lets precision="seconds" re-render once a second while the underlying timing stays millisecond-exact.

On This Page