Time display
StableThe clock face: Geist Mono digits in fixed cells, lit like the buttons, still or live, with a spoken form for screen readers.
Pass ms for a still value, or read and live for a running clock. While live, the seconds re-render only this component, once a second, and hundredths are written straight into the page on each frame, so the parent never re-renders. Countdowns show overtime with a plus, and fit shrinks the digits to the width of their container.
Sizes
import { MINUTE } from '@rchursin/product/lib/dates'import { TimeDisplay } from '@rchursin/product/timer'
/** One fixed cell per character, so nothing shifts as time passes. Lighter weight as it grows. */export default function TimeDisplaySizes() { return ( <div className="flex flex-col items-center gap-5"> <TimeDisplay ms={25 * MINUTE} size="lg" state="running" label="Focus, 25 minutes left" /> <div className="flex flex-wrap items-baseline justify-center gap-x-6 gap-y-3"> <TimeDisplay ms={25 * MINUTE} size="md" state="running" label="Medium" /> <TimeDisplay ms={25 * MINUTE} size="sm" state="running" label="Small" /> <TimeDisplay ms={25 * MINUTE} size="xs" state="running" label="Inline" /> </div> <TimeDisplay ms={65 * MINUTE + 9_000} size="sm" state="running" label="With hours" /> </div> )}Usage
import { TimeDisplay } from '@rchursin/product/timer'<TimeDisplay ms={25 * MINUTE} label="Focus, 25 minutes" /><TimeDisplay read={sw.read} live={sw.status === 'running'} mode="stopwatch" hundredths />Examples
States
Idle, running, paused (dimmed), overtime (grey with a plus), finished, and a stopwatch with hundredths.
import { MINUTE } from '@rchursin/product/lib/dates'import { TimeDisplay, type TimeState } from '@rchursin/product/timer'
const states: [name: string, ms: number, state: TimeState][] = [ ['Idle', 25 * MINUTE, 'idle'], ['Running', 12 * MINUTE + 34_000, 'running'], ['Paused', 12 * MINUTE + 34_000, 'paused'], ['Overtime', -(2 * MINUTE + 13_000), 'overflow'], ['Finished', 0, 'finished'],]
/** Paused dims the metal; overtime goes plain grey with a plus. The word next to the clock carries the meaning. */export default function TimeDisplayStates() { return ( <dl className="grid grid-cols-2 gap-x-8 gap-y-5"> {states.map(([name, ms, state]) => ( <div key={name} className="flex flex-col items-start gap-1.5"> <dt className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">{name}</dt> <dd> <TimeDisplay ms={ms} size="md" mode="countdown" state={state} label={`${name} example`} /> </dd> </div> ))} <div className="flex flex-col items-start gap-1.5"> <dt className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">Stopwatch</dt> <dd> <TimeDisplay ms={3 * MINUTE + 12_480} size="md" mode="stopwatch" hundredths state="running" label="Stopwatch example" /> </dd> </div> </dl> )}Live
Driven by useStopwatch. The run is saved in this browser, so a reload carries on where it was.
import { useId } from 'react'import { Button } from '@rchursin/product/ui'import { TimeDisplay, useStopwatch } from '@rchursin/product/timer'
/** * Live: pass `read` and `live`. The seconds re-render only the clock, once a second; the * hundredths are written straight into the page on each frame. The parent never re-renders. * The stopwatch state is saved in this browser, so a reload carries on where it was. */export default function TimeDisplayLive() { const labelId = useId() const sw = useStopwatch('library:time-display') const running = sw.status === 'running'
return ( <div className="flex flex-col items-center gap-4"> <p id={labelId} className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase"> Build time </p> <TimeDisplay read={sw.read} live={running} mode="stopwatch" hundredths size="lg" state={sw.status} labelledBy={labelId} /> <div className="flex gap-2"> <Button icon={running ? 'pause' : 'play'} variant={running ? 'secondary' : 'primary'} onClick={sw.toggle} className="min-w-24"> {running ? 'Pause' : sw.status === 'paused' ? 'Resume' : 'Start'} </Button> <Button variant="ghost" icon="rotate-ccw" onClick={sw.reset} disabled={sw.status === 'idle'}> Reset </Button> </div> </div> )}API
Time display props
| Prop | Type | Default | Description |
|---|---|---|---|
ms | number | 0 | A still value. Countdowns: time left (negative is overtime). Stopwatches: time elapsed. |
read | (now: number) => number | – | A live value instead of ms, called with Date.now() while live. |
live | boolean | false | Keep reading read: whole seconds once a second, hundredths every frame. |
mode | 'countdown' | 'stopwatch' | 'countdown' | Countdowns round seconds up (so 0:01 shows until the end); stopwatches round down. |
state | TimeState | 'idle' | 'idle' | 'running' | 'paused' | 'overflow' | 'finished'. The look only: put the word for it next to the clock. |
size | TimeSize | 'lg' | 'xs' | 'sm' | 'md' | 'lg' | 'xl': 13, 22, 36, 60 or 104 pixels, lighter weight as it grows. |
hundredths | boolean | false | Smaller hundredths after the seconds (stopwatches). |
alwaysHours | boolean | false | Show hours even under an hour. |
fit | boolean | false | Shrink to the parent’s width with a container query (never bigger than size). |
lit | boolean | size !== 'xs' | The lit-metal face. Always off in overtime. |
labelledBy | string | – | id of a visible label (preferred). |
label | string | – | Accessible name when there is no visible label. |
className | string | – | Classes on the face (or on the fitting wrapper with fit). |
CSS
| Class or variable | What it does |
|---|---|
tm-time | Geist Mono, tabular figures, line height 1, no wrapping. |
tm-cell / tm-sep / tm-sign | One fixed cell per digit (0.6em), colon (0.32em) and plus sign, so nothing jitters as time passes. |
tm-lit | The metal: a graded face with brushed grain clipped to the glyphs, graphite in light mode and silver in dark. Plain CanvasText in forced colours. |
[data-state="paused"] / [data-state="overflow"] | Paused dims the digits to half; overtime turns them plain grey. |
Accessibility
- The face is role="timer" with aria-atomic. A timer is never read out by itself, so it doesn’t chatter every second; owners announce milestones instead.
- Screen readers get the spoken form, not the digits: “24 minutes 59 seconds left”, “2 minutes 13 seconds over”, or “3 minutes 12 seconds” for a stopwatch.
- Name it with labelledBy (a visible label) or label.
- Paused and overtime change the look only; pair them with the word, as the Stopwatch and Countdown do.
Guidelines
Do
- Use read and live for running clocks, so only the digits redraw.
- Give every clock a visible label and point labelledBy at it.
Don’t
- Don’t keep the time in React state and re-render the page every second.
- Don’t add aria-live to the clock; announce milestones with useAnnounce.
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.
- Timer engineClocks that keep honest time: stopwatch and countdown hooks built on timestamps, one shared frame loop, and duration parsing.
- Progress squareProgress drawn as the brand’s square, closing clockwise from the top centre, and a row of squares for a set of focus sessions.
Source
The component’s own files, exactly as they run on this site.
/** * The clock face: Geist Mono digits in fixed cells, lit like the kit's metal buttons. * * <TimeDisplay ms={25 * MINUTE} mode="countdown" state="idle" /> still (specimens, summaries) * <TimeDisplay read={cd.read} live={cd.status === 'running'} mode="countdown" … /> live * * Live clocks never re-render their parent: the seconds re-render only this component, once a second (useTicker); * hundredths are written straight into one <span> on each frame (useFrame). * * Screen readers: the face is role="timer" (implicitly aria-live="off", so it is never read out by itself) with * aria-atomic, and its text is the spoken form ("24 minutes 59 seconds left"), not the digits. Milestones are * announced by the owner through useAnnounce, never by this element. */import { useCallback, useRef } from "react"import { cx } from "../../components/cx"import { clockParts, shownSeconds, spokenClock } from "./engine"import { useFrame, useTicker } from "./ticker"import "./timer.css"
export type TimeState = "idle" | "running" | "paused" | "overflow" | "finished"