Timer engine
StableClocks that keep honest time: stopwatch and countdown hooks built on timestamps, one shared frame loop, and duration parsing.
A clock here never adds “one second” per tick, because browser timers fire late, slow down in hidden tabs and stop when a laptop sleeps. Each clock remembers when its run began and what earlier runs banked, and works the time out from Date.now(), so it survives long tasks, sleep and reloads. useStopwatch and useCountdown wrap that and save it per viewer; useTicker and useFrame redraw the digits without re-rendering their parent.
A tiny stopwatch
import { useId } from 'react'import { lapText, TimeDisplay, useStopwatch } from '@rchursin/product/timer'import { Button } from '@rchursin/product/ui'
/** * A whole stopwatch in one hook. useStopwatch keeps only timestamps (when the run began, what * earlier runs banked) and saves them in this browser, so a reload or a sleeping laptop never * loses time. TimeDisplay redraws the digits; this component doesn't re-render while it runs. */export default function TinyStopwatch() { const labelId = useId() const sw = useStopwatch('library:hooks:stopwatch') const running = sw.status === 'running' const laps = [...sw.laps].reverse()
return ( <div className="flex w-full max-w-xs flex-col items-center gap-5"> <p id={labelId} className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase"> Test run </p>Usage
import { useStopwatch, useCountdown, useTicker, useFrame, parseDuration, clockText, elapsedAt } from '@rchursin/product/timer'const sw = useStopwatch('my-app:stopwatch')<TimeDisplay read={sw.read} live={sw.status === 'running'} mode="stopwatch" hundredths /><Button onClick={sw.toggle}>{sw.status === 'running' ? 'Pause' : 'Start'}</Button>Examples
Your own clock face
Plain ClockState in useState. useTicker re-renders once a second; useFrame fills the bar every frame without rendering.
import { useCallback, useRef, useState } from 'react'import { clockText, elapsedAt, useFrame, useTicker, type ClockState } from '@rchursin/product/timer'import { Button } from '@rchursin/product/ui'
/** * Your own clock face on the engine. The state is plain data: when the run began and what was * banked before. useTicker re-renders only when the shown second changes; useFrame fills the bar * on every frame by writing to the DOM, with no React render at all. */const IDLE: ClockState = { status: 'idle', anchorEpoch: null, accumulatedMs: 0 }
export default function OwnClock() { const [clock, setClock] = useState<ClockState>(IDLE) const running = clock.status === 'running' const bar = useRef<HTMLDivElement>(null)
const seconds = useTicker( useCallback((now: number) => Math.floor(elapsedAt(clock, now) / 1000), [clock]), running, )A countdown you can type
parseDuration reads the length and explains what it can’t. Try 25, 1:30, 1h or 12:75.
import { useState, type FormEvent } from 'react'import { formatDuration } from '@rchursin/product/lib/dates'import { parseDuration, TimeDisplay, useCountdown } from '@rchursin/product/timer'import { Button, Field, TextInput } from '@rchursin/product/ui'
/** * useCountdown with a length the person types. parseDuration reads "25", "12:30", "1h 30m" or * "90s" and returns a sentence to show when it can't. onFinish says how late the page noticed * the end, so a tab that slept through it can say "Finished 3 min ago" instead of "Finished". */export default function TypedCountdown() { const [text, setText] = useState('0:30') const [error, setError] = useState<string | null>(null) const [finished, setFinished] = useState<string | null>(null) const cd = useCountdown({ storageKey: 'library:hooks:countdown', initialMs: 30_000, onFinish: ({ lateMs }) => setFinished(lateMs > 5_000 ? `Finished ${formatDuration(lateMs)} ago.` : 'Time is up.'), }) const running = cd.status === 'running'API
useStopwatch(storageKey?, initial?)
| Prop | Type | Default | Description |
|---|---|---|---|
storageKey | string | 'timer:stopwatch' | Where it is saved (usePersistentState). The default is shared with the kit’s Stopwatch: pass your own. |
initial | StopwatchState | IDLE_STOPWATCH | State when nothing is saved. |
returns | { state, status, read, laps, start, pause, toggle, lap, reset, restore, peek } | – | read(now) gives elapsed ms; laps are the completed laps with fastest and slowest marked. The component only re-renders when you press something. |
useCountdown(options)
| Prop | Type | Default | Description |
|---|---|---|---|
storageKey | string | 'timer:countdown' | Where it is saved. Pass your own to keep it apart from the kit’s Countdown. |
initialMs | number | 5 * MINUTE | Starting length when nothing is saved. |
milestones | readonly number[] | [5 * MINUTE, MINUTE] | Time-left marks for onMilestone. Only marks shorter than the run are armed; one missed by more than 10 seconds (the page slept) is skipped. |
onMilestone | (msLeft: number) => void | – | A mark was passed. Announce it (useAnnounce), don’t put the clock in a live region. |
onFinish | (info: { finishedAt: number; lateMs: number }) => void | – | Reached zero. lateMs > 0 means the page noticed late (hidden, frozen or closed tab): say “Finished 3 min ago”. |
returns | { state, status, durationMs, read, start, pause, toggle, extend, setDuration, reset, restore, peek } | – | read(now) gives ms left. extend(ms) adds time while running or paused; setDuration(ms) stops and sets a new length. |
useDeadline(endAt, marks, onMark, onEnd)
| Prop | Type | Default | Description |
|---|---|---|---|
endAtrequired | number | null | – | Epoch ms of the end; null watches nothing. |
marksrequired | readonly number[] | – | Milestones as ms before the end. |
onMark, onEndrequired | (msLeft: number) => void, () => void | – | Called once each. The latest functions are always used. |
returns | void | – | Watched by the frame loop while visible, a setTimeout for hidden tabs, and a re-check on every wake. |
useTicker(compute, live) · useFrame(write, live)
| Prop | Type | Default | Description |
|---|---|---|---|
useTicker | <T extends string | number | boolean>(compute: (now: number) => T, live: boolean) => T | – | Re-renders only when compute’s result changes (once a second for whole seconds). Keep compute stable with useCallback; a new function means “inputs changed”. |
useFrame | (write: (now: number) => void, live: boolean) => void | – | Runs write after each render and on every frame while live. Write to the DOM yourself and only when something changed. |
onFrame(fn) · onWake(fn)
| Prop | Type | Default | Description |
|---|---|---|---|
onFrame | (fn: (now: number) => void) => () => void | – | One requestAnimationFrame loop for the whole page, running only while something listens and the page is visible. Returns unsubscribe. |
onWake | (fn: () => void) => () => void | – | Runs when the page may have missed time: shown again, focused, restored from the back/forward cache, unfrozen. |
Pure helpers
| Prop | Type | Default | Description |
|---|---|---|---|
elapsedAt(c, now) | (c: ClockState, now: number) => number | – | Banked time plus the current run; never negative, even if the system clock jumps back. |
clockText(seconds, alwaysHours?) | (seconds: number, alwaysHours = false) => string | – | “24:59”, “1:05:09”; negative seconds read as overtime: “+02:13”. |
lapText(ms) | (ms: number) => string | – | With hundredths: “00:25.43”. |
shownSeconds(ms, mode) | (ms: number, mode: 'countdown' | 'stopwatch') => number | – | What a clock shows: countdowns round up (25:00 for the first second), stopwatches round down. |
spokenClock(seconds, mode) | (seconds: number, mode: 'countdown' | 'stopwatch') => string | – | “24 minutes 59 seconds left”, for screen readers. |
parseDuration(input) | (input: string) => { ok: true; ms: number } | { ok: false; error: string } | – | “25” or “2.5” minutes, “12:30”, “1:05:00”, “1h 30m”, “90s”. Up to 24 hours. The error is a sentence you can show as is. |
Accessibility
- The hooks never announce anything. Use onMilestone and onFinish with useAnnounce, and keep the ticking digits out of live regions.
- TimeDisplay gives the face role="timer" and a spoken label (“24 minutes 59 seconds left”) instead of the digits.
- Nothing starts by itself: a clock only runs after a press. That keeps people in control of moving content (WCAG 2.2.2) and gives the chime the click it needs.
Guidelines
Do
- Give each clock its own storageKey.
- Pass read and live to TimeDisplay instead of rendering the time yourself every frame.
- Say how late a finish was noticed when lateMs is large.
Don’t
- Don’t count ticks with setInterval; work the time out from timestamps.
- Don’t use performance.now() for anything that should survive sleep or a reload.
In use
Related
- StopwatchA stopwatch with laps: a square that fills once a minute, hundredths drawn per frame, and Undo after a reset.
- CountdownA countdown with presets or a typed length, +1 min while it runs, a closing square and an honest finished banner.
- Time displayThe clock face: Geist Mono digits in fixed cells, lit like the buttons, still or live, with a spoken form for screen readers.
- TimerThe Focus screen: a focus session, a stopwatch and a countdown one switch apart, with the time log beside them.
- ChimeA soft two-note chime made with Web Audio: arm it inside a click, then play it later, even from a background tab.
Source
The component’s own files, exactly as they run on this site.
/** * The timing engine: pure functions over plain state. No React, no timers, no DOM. * * Why timestamps and not ticks * A browser timer can only fire late, never early: long tasks delay callbacks, nested timeouts are clamped to 4 ms, * hidden tabs run timers once a second (once a minute after 5 minutes hidden in Chrome), a frozen tab runs nothing, * and requestAnimationFrame stops in the background. A clock that adds "1 second" per tick drifts by all of that. * So a clock here only remembers WHEN its current run began (anchorEpoch) and how much time earlier runs banked * (accumulatedMs). The time is always worked out from the wall clock; ticks only decide when to draw. * * Why Date.now() and not performance.now() * performance.now() is monotonic, but on most systems it stops while the computer sleeps (close the lid for ten * minutes and a 25-minute focus would come back ten minutes short), and it can't survive a reload. Date.now() does * both. If the system clock is set backwards mid-run we clamp at zero instead of running the clock in reverse. */import { HOUR, MINUTE, formatDuration } from "../lib/dates"import type { TimeEntry } from "../data/sample"
export type RunStatus = "idle" | "running" | "paused"