Stopwatch
StableA stopwatch with laps: a square that fills once a minute, hundredths drawn per frame, and Undo after a reset.
It keeps its own state in the browser (under "timer:stopwatch" unless you pass storageKey), saved as start times rather than a ticking number, so a reload or a sleeping tab picks up exactly where it was. Laps are listed newest first, with the fastest and slowest named in words. Pass a useStopwatch() result as stopwatch to drive it from outside.
Updated View source
Stopwatch with laps
import { Stopwatch } from '@rchursin/product/timer'
/** * The whole tool. It keeps its own state in this browser (here under its own key, so it doesn't * share a run with the Timer app). Click its background, then Space starts and pauses, L laps, R resets. */export default function StopwatchApp() { return ( <div className="flex justify-center px-4 py-10 sm:px-8"> <Stopwatch storageKey="library:stopwatch" /> </div> )}Usage
import { Stopwatch } from '@rchursin/product/timer'<Stopwatch />Examples
Driven from outside
useStopwatch() holds the run; the line above reads its status and laps from the same object.
import { Stopwatch, useStopwatch } from '@rchursin/product/timer'
/** * Drive it from outside: hold the stopwatch with useStopwatch and pass it in. Anything else on the * page can then read the same run (status, laps) or press its buttons. */export default function StopwatchOutside() { const sw = useStopwatch('library:stopwatch-outside') const laps = sw.laps.length
return ( <div className="flex w-full flex-col items-center gap-6 px-4 py-8"> <p className="flex items-center gap-2 text-[13px] text-fg-2"> <span aria-hidden="true" className={sw.status === 'running' ? 'size-2 bg-fg' : 'size-2 ring-1 ring-fg-3 ring-inset'} /> {sw.status === 'running' ? 'Running' : sw.status === 'paused' ? 'Paused' : 'Not started'} · {laps} {laps === 1 ? 'lap' : 'laps'} </p> <Stopwatch stopwatch={sw} label="Test run" /> </div> )}API
Stopwatch props
| Prop | Type | Default | Description |
|---|---|---|---|
stopwatch | StopwatchApi | – | A useStopwatch() result to drive it from outside. Without it, the stopwatch keeps its own state. |
storageKey | string | 'timer:stopwatch' | Where its own state is saved (per viewer, in localStorage). Ignored when stopwatch is passed. |
label | string | 'Stopwatch' | Visible name above the digits, and the clock’s accessible name. |
onStatusChange | (status: RunStatus) => void | – | Told 'idle', 'running' or 'paused' whenever it changes (for example, to mark a tab as running). |
className | string | – | Classes on the wrapper. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Space | Starts, pauses or resumes while focus is on the stopwatch itself. On a button, Space presses that button. |
| L | Lap (while running). |
| R | Reset, with Undo in a toast. |
| Tab | Start or Pause, then Lap or Reset. |
- Starts, pauses, resumes and laps are announced politely (“Paused at 25.4 seconds”, “Lap 2, 31.2 seconds”) through the shared announcer, so wrap the app in AnnouncerProvider and ToastProvider once.
- The clock is role="timer", named by the visible label; the progress square is decorative.
- Shortcuts work only while focus is inside the stopwatch, never while typing in a field or inside a popover.
- The laps table has a caption (“Laps, newest first”) and row headers. The lap in progress changes every frame on screen but reads “In progress”.
- Fastest and slowest are named with a word and an icon (solid and dashed tags), never by colour.
Guidelines
Do
- Give each stopwatch on a page its own storageKey.
- Click its background, or Tab to one of its buttons, before using the letter keys.
Don’t
- Don’t put two stopwatches on the same storageKey unless they should share one run.
- Don’t use it for a deadline; use Countdown.
In use
Related
- Laps tableThe Stopwatch’s table on its own: lap, lap time and total, newest first, with the fastest and slowest named.
- Time displayThe clock face: Geist Mono digits in fixed cells, lit like the buttons, still or live, with a spoken form for screen readers.
- Timer engineClocks that keep honest time: stopwatch and countdown hooks built on timestamps, one shared frame loop, and duration parsing.
- TimerThe Focus screen: a focus session, a stopwatch and a countdown one switch apart, with the time log beside them.
Source
The component’s own files, exactly as they run on this site.
/** * Stopwatch with laps. The square fills once a minute, like a seconds hand; hundredths are drawn per frame. * * <Stopwatch /> keeps its own state (saved under "timer:stopwatch") * <Stopwatch stopwatch={useStopwatch()} /> or drive it from outside * * Keys (while focus is inside it): Space start / pause · L lap · R reset (with Undo). * Laps: newest first; the fastest and slowest are named in words with an icon, never by colour alone. */import { useEffect, useId, useRef, type ReactNode } from "react"import { cx } from "../../components/cx"import { Icon } from "../icons/Icon"import { useAnnounce } from "../lib/announce"import { formatDuration } from "../lib/dates"import { useHotkeys } from "../lib/hotkeys"import { Button } from "../ui/button"import { useToast } from "../ui/toast"import { ClockText, KeyHints, outsideOverlays, spaceToggles } from "./bits"import { elapsedAt, lapText, type Lap, type RunStatus } from "./engine"import { useStopwatch, type StopwatchApi } from "./hooks"