Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Race timer

New

A game HUD stopwatch: mm:ss.cs on a dark pill, counting up or down, red in the last seconds, with laps and a finish.

race-timer/race-timer.tsx
Live · interactive
00:30.00

    The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

    When to use

    • A timed challenge in a game: a race, a time trial or a puzzle room with a limit, where the player glances at the clock while playing.
    • A countdown the player must beat, with a warning they feel in the last seconds (red digits that throb once a second) before time runs out.
    • A run split into laps or checkpoints, where each split should say at once whether it was faster or slower than the best.
    • A finish that deserves a moment: the time frozen and flashed under a "Finish" or "New record" tag.
    • A HUD clock on any background: the pill, its tag and its splits bring their own dark plates.

    When not to use

    • A plain duration in an app, such as a call or a recording. Use a small text timer next to the control.
    • A wait the player cannot influence, such as a respawn or a cooldown. Use a radial cooldown or a loading tip.
    • A countdown to a date or an event days away. Use a countdown in days and hours; hundredths only add noise.
    • Timing an action against a target beat, such as a rhythm or a quick-time press. Use a button prompt with a ring.
    • A score or a count of things. Use a coin or score counter; this one only reads as time.
    • A result table after the race. Use a results list or a game-over screen; the splits here keep only the last three.

    You can add this component with the shadcn CLI or by hand:

    Into components/race-timer of any React project, with no setup:

    Terminal
    npx shadcn@latest add https://brunodelorence.com/r/race-timer.json

    Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

    Usage

    trial-hud.tsx
    import { TimerPill } from './race-timer';export function TrialHud({ left, laps }: { left: number; laps: number }) {  return <TimerPill time={left} warn={left < 10_000} lap={laps} label="Trial time left" />;}

    Each state below is a live instance, locked so you can study it.

    The pieces it is built from, each with its measurements read live.

    The stopwatch a game puts on screen for a timed challenge: minutes, seconds and hundredths in italic on a dark pill, a small quill on each cap. It counts the run up or the time left down; in the last seconds before the limit the digits turn red and throb once a second. A lap sweeps a sheen across it and drops a split under it, teal when faster than the best, red when slower; crossing the line freezes and flashes the time under a "Finish" or "New record" tag, and running out pins 00:00.00 in red with a shake.

    • The pill is the kit’s 159 × 31 units with fully round caps; the digits are 20 units of italic tabular figures, tracked in by 0.04 em so the eight characters span the kit’s 78 units, and a 15 × 7 SVG quill sits 9.5 units in from each end, the right one mirrored. Every size is a multiple of one unit, set from the component’s own width (a container query unit) up to 1.75 px, so it scales as one drawing.
    • The clock is performance.now() based: what has run is a stored base plus the time since the last start. A small child component re-renders the pill on requestAnimationFrame only while it runs, so the buttons and splits do not re-render 60 times a second, and the clock holds still while the timer is off screen or the tab hidden.
    • Counting up shows the run so far, counting down the time left; either way the run has a limit. In the last warnAt seconds before it the digits turn red, and each whole second remounts the digits and a red inner glow (by React key) to replay a throb, harder in the last three. At the limit the time is pinned, the pill shakes and a "Time up" tag rises.
    • A lap stores the split since the last one and compares it with the best so far: the split plate drops in under the pill with its delta in teal (faster) or red (slower), and older splits move down by a transition of top and dim their words, three kept. The lap also sweeps a sheen across the pill and flicks the quills outward.
    • Finishing freezes the time; a white flash, a sheen and three blinks play on the digits, the quills spread and stay spread, and a small dark tag rises above with a slight overshoot: "New record" in teal when the time beats the one to beat, else "Finish". Paused, the digits blink in steps.
    • Shown on its own, with no game scene behind it, it brings its own footing as a HUD does: the pill, the splits and the tag are opaque dark plates with the kit’s colours on any page; only a faint rim, a soft drop, focus and the buttons follow the scheme through light-dark().
    • Untouched, a demo plays a minute-long count up to a new record with two laps, then a 15-second countdown with a slower lap, a pause and a time up, on a 31.6-second loop; any button, key or gamepad press hands it over.
    PropTypeDefaultDescription
    mode'up' | 'down'—RaceTimer: count the run up or the time left down. Without it the playground starts counting down and the demo plays both.
    durationnumber30RaceTimer: the limit, in seconds: where a countdown starts and where any run times out.
    warnAtnumber10RaceTimer: the red, throbbing warning starts this many seconds before the limit.
    recordnumber—RaceTimer: the time to beat, in ms; finishing under it raises "New record".
    controlsbooleantrueRaceTimer: show the buttons and listen to keys and a connected gamepad.
    onLap(split: number, lap: number) => void—RaceTimer: called with each lap’s split, in ms.
    onFinish(elapsed: number, timedOut: boolean) => void—RaceTimer: called when a run ends, with its time in ms.
    timenumber—TimerPill: the time to show, in ms, as mm:ss.cs.
    warnbooleanfalseTimerPill: red digits, for the last seconds before a limit.
    phase'ready' | 'running' | 'paused' | 'finished' | 'timeup''running'TimerPill: paused blinks the digits, finished flashes them and spreads the quills, timeup shakes the pill.
    beatnumber—TimerPill: a whole second inside the warning; each new value plays one throb (hard adds force).
    lapnumber0TimerPill: a lap count; each new value sweeps a sheen and flicks the quills.
    tag'finish' | 'record' | 'timeup' | nullnullTimerPill: the small tag above the pill.
    labelstring'Race timer'What a screen reader calls the timer.
    autoplaybooleantrueRaceTimer: plays the demo while on screen and untouched; never under reduced motion.
    defaultState{ phase?: TimerPhase; elapsed?: number; laps?: number[] }—RaceTimer: opens on this frame, held still, with the demo off: the phase (paused when left out), the time run in ms and the laps already run; the first input carries on from it.
    colorScheme'light' | 'dark' | 'system'—RaceTimer: forces a scheme for the buttons and the rim; without it, the page’s color-scheme.
    classNamestring—Extra class on the root.

    The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

    race-timer.tsx
    // Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type RefObject,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './race-timer.module.css';/* * Race timer: the stopwatch a game puts at the top of the screen for a timed challenge, a race * against a rival or a trial against the clock. It reads at a glance as minutes, seconds and * hundredths (mm:ss.cs) on a dark pill with a small quill on each cap. * * - TimerPill is the pill alone, driven by props: the time to show in ms, whether it warns (red *   digits), its phase and the flourishes to play. It counts nothing itself. * - RaceTimer is a playground around it: a clock that counts up (the run so far) or down (the
    race-timer.module.css
    /* Race timer (race-timer.tsx): the stopwatch pill alone on the page's stage, its tag above it,   the lap splits under it, and the playground's buttons below.   Every size is a multiple of --u, one design unit (the kit's pixel): the root sets it from its own   width (a container query unit), up to 1.75 px, so the timer scales as one drawing; --timer-unit   fixes it. The kit's measures: the pill 159 × 31 with fully round caps; italic digits 21 high   centred on it; a 15 × 7 quill 9.5 in from each end, the right one mirrored; the tag a 19-high   pill of 12 italic; each split a 20-high pill, 25 apart.   Colours: the pill, the tag and the splits keep the kit's on any page (a dark warm grey plate,   bone digits, the quills in the panel grey, red for the warning, teal for a faster split), as a   game's HUD does. What sits straight on the page follows the scheme through light-dark(): the   faint rim and drop under the plates, focus, and the buttons.   Motion: the run's pill pops in by keyframes and its quills slide out after it; the warning's   throb, the lap's sheen and quill flick, the finish flash and blink and the time-up shake are   keyframes replayed by remounting (React keys). Split plates move by a transition of top. */.root {  /* The HUD. */  --plate: #484641;  --ink: #e2ded3;  --quill: #66645d;  --muted: #aaa69a;
    use-autoplay.ts
    // Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` alone says whether it is on screen * in a visible tab, for clocks that should stop when nobody can see them. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);
    • The pill is a role="timer" element named by label (with "counting up" or "counting down"), so its time is read on demand without being announced every hundredth.
    • A polite live region says what changes: go, paused at a time, the first warning second, each lap with its split and how it compares, finish or new record with the time, time up, reset.
    • The playground is a focusable group: Enter or Space starts, pauses and resumes, L takes a lap, F finishes, R or Backspace resets; the buttons carry aria-keyshortcuts and the controller face they stand for, and unavailable ones are aria-disabled. A connected gamepad’s A, X, Y and B do the same.
    • The quills and glows are aria-hidden; warning is red plus a throb and the words in the live region, never colour alone.
    • Under reduced motion there is no pop, throb, sheen, flick, flash, shake or slide, and no demo; the red, the tags and the splits still change, and a paused time is shown dimmed instead of blinking.