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…

"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"

/** 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
- 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 sourceperformance.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). 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-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