# Race timer

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

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/race-timer. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Games · HUD.

## Anatomy

1. **Tag**: A small dark plate that rises above the pill when a run ends: "Finish", "New record" in teal or "Time up" in red, between two quills.
2. **Pill**: The `role="timer"` plate, 159 × 31 units with fully round caps (278 × 54 px at 640 px wide). It keeps its dark plate on any page.
3. **Digits**: mm:ss.cs in italic tabular figures, centred: bone white, red in the last `warnAt` seconds and at time up.
4. **Quill**: A small SVG flourish 9.5 units in from each cap, the right one mirrored; they slide out on a lap and stay spread at the finish.
5. **Split**: One plate per lap under the pill, newest on top, three kept; older ones move down and dim their words.
6. **Delta**: The split against the best so far: teal with − when faster, red with + when slower. The first lap has none.
7. **Action button**: Start or pause, Lap, Finish and Reset, each with its controller face; unavailable ones are `aria-disabled` and dimmed.
8. **Count toggle**: Count up or Count down, `aria-pressed`; switching starts a fresh run in that mode.

## 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.

## Installation

Install it with the shadcn CLI, into components/race-timer of any React project:

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```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" />;
}
```

## States

- **Ready**: Before a run: a countdown shows its whole limit (`duration={30}`).
- **Counting up**: `mode="up"`: the run so far, held here at 4.21 s after one lap.
- **Counting down**: `mode="down"`: the time left, still bone white above `warnAt`.
- **Warning**: Under `warnAt` seconds left: red digits, which throb once a second while the clock runs.
- **Paused**: The digits blink in steps (dimmed under reduced motion).
- **Laps**: Three splits, newest on top: lap 3 slower than the best in red, lap 2 faster in teal.
- **Finish**: The time frozen and flashed, the quills spread, a "Finish" tag above.
- **New record**: Finished under `record`: the tag says "New record" in teal.
- **Time up**: The limit reached: 00:00.00 pinned in red, a shake and a red "Time up" tag.
- **With buttons**: Paused with `controls`: Resume, Finish and Reset live; Lap dimmed until it runs again.

## How it works

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

## API reference

- **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.
- **duration** (`number`, default `30`): RaceTimer: the limit, in seconds: where a countdown starts and where any run times out.
- **warnAt** (`number`, default `10`): RaceTimer: the red, throbbing warning starts this many seconds before the limit.
- **record** (`number`): RaceTimer: the time to beat, in ms; finishing under it raises "New record".
- **controls** (`boolean`, default `true`): RaceTimer: 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.
- **time** (`number`): TimerPill: the time to show, in ms, as mm:ss.cs.
- **warn** (`boolean`, default `false`): TimerPill: red digits, for the last seconds before a limit.
- **phase** (`'ready' | 'running' | 'paused' | 'finished' | 'timeup'`, default `'running'`): TimerPill: paused blinks the digits, finished flashes them and spreads the quills, timeup shakes the pill.
- **beat** (`number`): TimerPill: a whole second inside the warning; each new value plays one throb (hard adds force).
- **lap** (`number`, default `0`): TimerPill: a lap count; each new value sweeps a sheen and flicks the quills.
- **tag** (`'finish' | 'record' | 'timeup' | null`, default `null`): TimerPill: the small tag above the pill.
- **label** (`string`, default `'Race timer'`): What a screen reader calls the timer.
- **autoplay** (`boolean`, default `true`): RaceTimer: 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.
- **className** (`string`): Extra class on the root.

## Accessibility

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

## Source

### race-timer/race-timer.tsx

```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
 *   time left) towards a limit, with laps, a finish line and a reset, by buttons, keys or a
 *   gamepad. The clock is performance.now() based; a small child re-renders the pill on
 *   requestAnimationFrame while it runs, so nothing else re-renders 60 times a second.
 * - Moments: the pill pops in at the start of a run and its quills slide out to the caps; in the
 *   last `warnAt` seconds before the limit the digits turn red and throb once a second (harder in
 *   the last three); a lap sweeps a sheen across the pill, flicks the quills and drops a split
 *   plate under it (faster than the best split in teal, slower in red); finishing freezes the
 *   time, flashes it and raises a "Finish" or "New record" tag; running out pins 00:00.00 in red,
 *   shakes the pill and raises "Time up". Paused, the digits blink.
 * - Uncontrolled, a demo plays two runs while it is on screen and untouched (one counting up to
 *   a new record, one counting down into time up); any real input hands it to the reader. The
 *   clock pauses itself while the timer is off screen or the tab hidden.
 * - The pill, the splits and the tag carry their own dark plate and keep the kit's colours on a
 *   light page and a dark one, as a game's HUD does; only the controls under them, focus and a
 *   faint rim follow the scheme. `--timer-unit` fixes the drawing's unit.
 */

export type TimerMode = 'up' | 'down';
export type TimerPhase = 'ready' | 'running' | 'paused' | 'finished' | 'timeup';
export type TimerTag = 'finish' | 'record' | 'timeup';

/** The demo's own runs: a minute counting up, 15 seconds counting down, and a record to beat. */
const DEMO_LIMIT: Record<TimerMode, number> = { up: 60, down: 15 };
const DEMO_RECORD = 8120;
/** Splits kept under the pill. */
const SPLITS = 3;
/** The demo: [seconds from its start, step]; it loops after DEMO_LENGTH. */
type Step = 'up' | 'down' | 'start' | 'pause' | 'lap' | 'finish';
const SCRIPT: [number, Step][] = [
  [0, 'up'],
  [0.7, 'start'],
  [3.3, 'lap'],
  [5.8, 'lap'],
  [8.1, 'finish'],
  [11.4, 'down'],
  [12.1, 'start'],
  [16.1, 'lap'],
  [20.6, 'lap'],
  [22.2, 'pause'],
  [23.4, 'start'],
];
/** 12.1 s + a 15 s countdown paused for 1.2 s, then the time-up tag holds a while. */
const DEMO_LENGTH = 31.6;

/** mm:ss.cs, hundredths floored; clamps at 99:59.99. */
export function formatTime(ms: number) {
  const total = Math.max(0, Math.min(Math.floor(ms / 10), 599999));
  const cs = total % 100;
  const s = Math.floor(total / 100) % 60;
  const m = Math.floor(total / 6000);
  const two = (n: number) => String(n).padStart(2, '0');
  return `${two(m)}:${two(s)}.${two(cs)}`;
}

/** Seconds for a screen reader: "8.41 seconds", "1 minute 2.5 seconds". */
function speak(ms: number) {
  const total = Math.max(0, Math.floor(ms / 10)) / 100;
  const m = Math.floor(total / 60);
  const s = Number((total - m * 60).toFixed(2));
  const sec = `${s} ${s === 1 ? 'second' : 'seconds'}`;
  return m ? `${m} ${m === 1 ? 'minute' : 'minutes'} ${sec}` : sec;
}

/** The quill on a cap: a tapering stroke ending in a small ring. Drawn for the left cap. */
function Quill({ side }: { side: 'left' | 'right' }) {
  return (
    <svg
      className={styles.quill}
      data-part="quill"
      data-side={side}
      viewBox="0 0 15 7"
      aria-hidden="true"
      focusable="false"
    >
      <path
        className={styles.quillInk}
        fillRule="evenodd"
        d="M0 3.9C3 3.1 6 2.4 8.6 2.2L9.4 2.5C9.8 1 12 0.4 12.8 1.7C13.4 2.5 14.3 3.2 15 3.9C13.6 5.1 11.8 6.4 9.8 6.35C7 6.25 3.5 4.8 0 3.9ZM10.15 3.5a0.95 0.6 0 1 0 1.9 0a0.95 0.6 0 1 0 -1.9 0Z"
      />
    </svg>
  );
}

export type TimerPillProps = {
  /** The time to show, in ms (mm:ss.cs). */
  time: number;
  /** Red digits: the last seconds before the limit. */
  warn?: boolean;
  /** 'running' and 'ready' are still; 'paused' blinks; 'finished' flashes; 'timeup' shakes. */
  phase?: TimerPhase;
  /** A whole second inside the warning, to throb on (a new value plays it once). */
  beat?: number;
  /** Throb harder (the last three seconds). */
  hard?: boolean;
  /** A lap count: a new value sweeps a sheen across the pill and flicks the quills. */
  lap?: number;
  /** A tag above the pill: 'finish', 'record' or 'timeup'. */
  tag?: TimerTag | null;
  /** What a screen reader calls the timer. */
  label?: string;
  className?: string;
};

const TAG_WORDS: Record<TimerTag, string> = {
  finish: 'Finish',
  record: 'New record',
  timeup: 'Time up',
};

/** The pill alone: digits between two quills, with its tag above. Purely a display. */
export function TimerPill({
  time,
  warn = false,
  phase = 'running',
  beat,
  hard = false,
  lap = 0,
  tag = null,
  label = 'Race timer',
  className = '',
}: TimerPillProps) {
  return (
    <div className={`${styles.pillBox} ${className}`} data-part="timer">
      <div className={styles.tagRow}>
        {tag && (
          <p className={styles.tag} data-part="tag" data-kind={tag}>
            <Quill side="left" />
            <span className={styles.tagWord}>{TAG_WORDS[tag]}</span>
            <Quill side="right" />
          </p>
        )}
      </div>
      <div
        className={styles.pill}
        data-part="pill"
        data-phase={phase}
        data-warn={warn || undefined}
        role="timer"
        aria-label={label}
      >
        <span
          key={`glow-${beat}`}
          className={styles.glow}
          data-beat={beat !== undefined || undefined}
          data-hard={hard || undefined}
          aria-hidden="true"
        />
        <span
          key={`sheen-${lap}`}
          className={styles.sheen}
          data-on={lap > 0 || undefined}
          aria-hidden="true"
        />
        <span
          key={`left-${lap}`}
          className={styles.cap}
          data-side="left"
          data-flick={lap > 0 || undefined}
        >
          <Quill side="left" />
        </span>
        <span
          key={`digits-${beat}`}
          className={styles.digits}
          data-beat={beat !== undefined || undefined}
          data-hard={hard || undefined}
        >
          {/* The figures alone, so the part outlines the time rather than the whole pill. */}
          <span data-part="digits">{formatTime(time)}</span>
        </span>
        <span
          key={`right-${lap}`}
          className={styles.cap}
          data-side="right"
          data-flick={lap > 0 || undefined}
        >
          <Quill side="right" />
        </span>
      </div>
    </div>
  );
}

type Split = { n: number; split: number; delta: number | null };

/** The clock: what has run so far is `base` plus, while running, the time since `since`. */
type Clock = { base: number; since: number; running: boolean };
const elapsedOf = (clock: Clock, now: number) =>
  clock.base + (clock.running ? now - clock.since : 0);

/** The pill, re-rendered every frame while the clock runs (and only then). */
function LivePill({
  clock,
  limit,
  mode,
  running,
  onFrame,
  ...pill
}: Omit<TimerPillProps, 'time'> & {
  clock: RefObject<Clock>;
  limit: number;
  mode: TimerMode;
  running: boolean;
  onFrame: (elapsed: number) => void;
}) {
  const [, setFrame] = useState(0);
  const frame = useEffectEvent((now: number) => {
    onFrame(elapsedOf(clock.current, now));
    setFrame(now);
  });
  useEffect(() => {
    if (!running) return;
    let raf = requestAnimationFrame(function tick(now) {
      frame(now);
      raf = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(raf);
  }, [running]);
  const elapsed = Math.min(limit, elapsedOf(clock.current, performance.now()));
  return <TimerPill time={mode === 'up' ? elapsed : limit - elapsed} {...pill} />;
}

type Action = 'toggle' | 'lap' | 'finish' | 'reset';

/** A frame to open the timer on, held still: the docs' locked states, or a run restored. */
export type RaceTimerSnapshot = {
  /** The phase to open in ('paused' when left out). A 'running' frame is held until Start. */
  phase?: TimerPhase;
  /** The time run so far, in ms (the limit, for 'timeup'). */
  elapsed?: number;
  /** The laps already run, each split in ms, the first one first (the last three show). */
  laps?: number[];
};

/** What a snapshot opens the timer with: the clock held at its time, its splits and its tag. */
function openAt(snapshot: RaceTimerSnapshot, limit: number, record: number | null) {
  const phase = snapshot.phase ?? 'paused';
  const at = phase === 'timeup' ? limit : Math.min(limit, Math.max(0, snapshot.elapsed ?? 0));
  const laps = snapshot.laps ?? [];
  let best: number | null = null;
  let lastLap = 0;
  const splits: Split[] = laps.map((split, index) => {
    const delta = best === null ? null : split - best;
    best = best === null ? split : Math.min(best, split);
    lastLap += split;
    return { n: index + 1, split, delta };
  });
  const tag: TimerTag | null =
    phase === 'timeup'
      ? 'timeup'
      : phase === 'finished'
        ? record !== null && at < record
          ? 'record'
          : 'finish'
        : null;
  return {
    phase,
    clock: { base: at, since: 0, running: false },
    splits: splits.reverse().slice(0, SPLITS),
    lapCount: laps.length,
    lastLap,
    best,
    tag,
  };
}

const BUTTONS: { action: Action; glyph: string; key: string; keys: string }[] = [
  { action: 'toggle', glyph: 'A', key: 'Enter', keys: 'Enter Space' },
  { action: 'lap', glyph: 'X', key: 'L', keys: 'L' },
  { action: 'finish', glyph: 'Y', key: 'F', keys: 'F' },
  { action: 'reset', glyph: 'B', key: 'R', keys: 'R Backspace' },
];

export type RaceTimerProps = {
  /** 'down' shows the time left, 'up' the time run so far. Without it, the demo plays both. */
  mode?: TimerMode;
  /** The limit, in seconds: where a countdown starts and where any run times out. */
  duration?: number;
  /** The red, throbbing warning starts this many seconds before the limit. */
  warnAt?: number;
  /** The time to beat, in ms: finishing under it raises "New record". */
  record?: number;
  /** Show the buttons and listen to keys and a connected gamepad. */
  controls?: boolean;
  /** Plays a short demo while on screen and untouched (never under reduced motion). */
  autoplay?: boolean;
  /** Opens on this frame, held still, with the demo off; the first input carries on from it. */
  defaultState?: RaceTimerSnapshot;
  /** Called with each lap's split, in ms. */
  onLap?: (split: number, lap: number) => void;
  /** Called when the run ends: its time in ms, and whether the clock ran out. */
  onFinish?: (elapsed: number, timedOut: boolean) => void;
  /** What a screen reader calls the timer. */
  label?: string;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

export function RaceTimer({
  mode: modeProp,
  duration = 30,
  warnAt = 10,
  record: recordProp,
  controls = true,
  autoplay = true,
  defaultState,
  onLap,
  onFinish,
  label = 'Race timer',
  colorScheme,
  className = '',
}: RaceTimerProps) {
  const { ref: root, take, visible, playing } = useAutoplay<HTMLDivElement>(12000);
  const [opening] = useState(() =>
    defaultState ? openAt(defaultState, duration * 1000, recordProp ?? null) : null,
  );
  const [mode, setMode] = useState<TimerMode>(modeProp ?? 'down');
  const [limit, setLimit] = useState(duration * 1000);
  const [phase, setPhase] = useState<TimerPhase>(opening?.phase ?? 'ready');
  const [round, setRound] = useState(0);
  const [splits, setSplits] = useState<Split[]>(opening?.splits ?? []);
  const [lapCount, setLapCount] = useState(opening?.lapCount ?? 0);
  const [tag, setTag] = useState<TimerTag | null>(opening?.tag ?? null);
  const [beat, setBeat] = useState<number | undefined>(undefined);
  const [message, setMessage] = useState('');
  const clock = useRef<Clock>(opening?.clock ?? { base: 0, since: 0, running: false });
  const lastLap = useRef(opening?.lastLap ?? 0);
  const best = useRef<number | null>(opening?.best ?? null);
  const record = useRef<number | null>(recordProp ?? null);
  const shownBeat = useRef<number | undefined>(undefined);
  const autoPaused = useRef(false);
  const demoTimers = useRef(new Set<number>());

  useEffect(() => {
    if (modeProp) setMode(modeProp);
  }, [modeProp]);
  useEffect(() => {
    record.current = recordProp ?? null;
  }, [recordProp]);

  const now = () => performance.now();
  const elapsed = () => Math.min(limit, elapsedOf(clock.current, now()));

  /** A fresh run: the pill pops in again at its start time. */
  const reset = (nextMode = mode, nextLimit = duration * 1000) => {
    clock.current = { base: 0, since: 0, running: false };
    lastLap.current = 0;
    best.current = null;
    shownBeat.current = undefined;
    setMode(nextMode);
    setLimit(nextLimit);
    setPhase('ready');
    setSplits([]);
    setLapCount(0);
    setTag(null);
    setBeat(undefined);
    setRound((r) => r + 1);
  };

  const start = () => {
    clock.current = { ...clock.current, since: now(), running: true };
    setPhase('running');
    setMessage(phase === 'paused' ? 'Resumed.' : 'Go.');
  };
  const pause = () => {
    const at = elapsed();
    clock.current = { base: at, since: 0, running: false };
    setPhase('paused');
    setMessage(`Paused at ${speak(mode === 'up' ? at : limit - at)}.`);
  };

  const lap = () => {
    if (phase !== 'running') return;
    const at = elapsed();
    const split = at - lastLap.current;
    lastLap.current = at;
    const delta = best.current === null ? null : split - best.current;
    best.current = best.current === null ? split : Math.min(best.current, split);
    const n = lapCount + 1;
    setLapCount(n);
    setSplits((list) => [{ n, split, delta }, ...list].slice(0, SPLITS));
    setMessage(
      `Lap ${n}: ${speak(split)}${
        delta === null ? '' : `, ${speak(Math.abs(delta))} ${delta <= 0 ? 'faster' : 'slower'}`
      }.`,
    );
    onLap?.(split, n);
  };

  const finish = () => {
    if (phase !== 'running' && phase !== 'paused') return;
    const at = elapsed();
    clock.current = { base: at, since: 0, running: false };
    const beaten = record.current !== null && at < record.current;
    if (record.current === null || at < record.current) record.current = at;
    setPhase('finished');
    setTag(beaten ? 'record' : 'finish');
    setBeat(undefined);
    setMessage(`${beaten ? 'New record' : 'Finish'}: ${speak(at)}.`);
    onFinish?.(at, false);
  };

  const timeUp = () => {
    clock.current = { base: limit, since: 0, running: false };
    setPhase('timeup');
    setTag('timeup');
    setBeat(undefined);
    setMessage('Time up.');
    onFinish?.(limit, true);
  };

  /** Starts a run just reset, on the next frame, once the fresh pill has rendered. */
  const startFresh = () =>
    requestAnimationFrame(() => {
      clock.current = { base: 0, since: now(), running: true };
      setPhase('running');
      setMessage('Go.');
    });

  const act = (action: Action) => {
    if (action === 'toggle') {
      // A 'running' snapshot is a held frame: Start carries on from it.
      if (phase === 'running' && !clock.current.running) start();
      else if (phase === 'running') pause();
      else if (phase === 'ready' || phase === 'paused') start();
      else {
        reset();
        startFresh();
      }
    } else if (action === 'lap') lap();
    else if (action === 'finish') finish();
    else {
      reset();
      setMessage('Reset.');
    }
  };

  /** Every frame while running: the warning's beats, and the limit. */
  const onFrame = (at: number) => {
    if (!clock.current.running) return;
    const left = limit - at;
    if (left <= 0) {
      timeUp();
      return;
    }
    const second = Math.ceil(left / 1000);
    if (left <= warnAt * 1000 && second !== shownBeat.current) {
      if (shownBeat.current === undefined) setMessage(`${second} seconds left.`);
      shownBeat.current = second;
      setBeat(second);
    }
  };

  // Off screen or in a hidden tab, the clock holds still, and picks up again when seen.
  const onVisible = useEffectEvent((seen: boolean) => {
    if (!seen && clock.current.running) {
      autoPaused.current = true;
      clock.current = { base: elapsed(), since: 0, running: false };
      setPhase('paused');
    } else if (seen && autoPaused.current) {
      autoPaused.current = false;
      clock.current = { ...clock.current, since: now(), running: true };
      setPhase('running');
    }
  });
  useEffect(() => onVisible(visible), [visible]);

  // The demo: two runs on a loop while it plays.
  const step = useEffectEvent((what: Step) => {
    if (what === 'up' || what === 'down') {
      record.current = recordProp ?? DEMO_RECORD;
      const next = modeProp ?? what;
      reset(next, DEMO_LIMIT[next] * 1000);
    } else if (what === 'start') start();
    else if (what === 'pause') pause();
    else if (what === 'lap') lap();
    else finish();
  });
  const run = autoplay && !defaultState && playing;
  useEffect(() => {
    if (!run) return;
    const timers = demoTimers.current;
    const loop = () => {
      for (const [at, what] of SCRIPT) timers.add(window.setTimeout(() => step(what), at * 1000));
      timers.add(window.setTimeout(loop, DEMO_LENGTH * 1000));
    };
    loop();
    return () => {
      timers.forEach((timer) => window.clearTimeout(timer));
      timers.clear();
    };
  }, [run]);

  /** Real input: the reader takes over; a run the demo left behind starts over at their limit. */
  const handOver = () => {
    const fromDemo = demoTimers.current.size > 0;
    take();
    if (fromDemo) {
      demoTimers.current.forEach((timer) => window.clearTimeout(timer));
      demoTimers.current.clear();
      record.current = recordProp ?? null;
      reset();
      return true;
    }
    return false;
  };
  const input = (action: Action) => {
    const fresh = handOver();
    if (fresh && action === 'toggle') startFresh();
    else if (!fresh) act(action);
  };
  const switchMode = (next: TimerMode) => {
    handOver();
    reset(next);
  };

  const keyAction = (event: ReactKeyboardEvent): Action | null => {
    if (event.metaKey || event.ctrlKey || event.altKey) return null;
    const key = event.key.toLowerCase();
    if (key === 'l') return 'lap';
    if (key === 'f') return 'finish';
    if (key === 'r' || key === 'backspace') return 'reset';
    if ((key === 'enter' || key === ' ') && !(event.target instanceof HTMLButtonElement))
      return 'toggle';
    return null;
  };
  const onKeyDown = (event: ReactKeyboardEvent) => {
    const action = keyAction(event);
    if (!action) return;
    event.preventDefault();
    if (event.repeat) return;
    input(action);
  };

  // A gamepad: polled once one is connected (browsers only report one after a user gesture).
  const [pad, setPad] = useState(false);
  useEffect(() => {
    if (!controls || !('getGamepads' in navigator)) return;
    const connected = () => setPad(navigator.getGamepads().some(Boolean));
    connected();
    window.addEventListener('gamepadconnected', connected);
    window.addEventListener('gamepaddisconnected', connected);
    return () => {
      window.removeEventListener('gamepadconnected', connected);
      window.removeEventListener('gamepaddisconnected', connected);
    };
  }, [controls]);
  const padHeld = useRef(new Set<Action>());
  const poll = useEffectEvent(() => {
    const down = new Set<Action>();
    navigator.getGamepads().forEach((gamepad) => {
      if (!gamepad) return;
      const b = (i: number) => gamepad.buttons[i]?.pressed;
      // The face buttons by position, as the kit's controller lays them out: A right, B bottom,
      // X top, Y left.
      if (b(1)) down.add('toggle');
      if (b(3)) down.add('lap');
      if (b(2)) down.add('finish');
      if (b(0)) down.add('reset');
    });
    for (const action of down) if (!padHeld.current.has(action)) input(action);
    padHeld.current = down;
  });
  useEffect(() => {
    if (!pad || !visible) return;
    let frame = requestAnimationFrame(function tick() {
      poll();
      frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [pad, visible]);

  const warn = (limit - elapsed() <= warnAt * 1000 && phase !== 'finished') || phase === 'timeup';
  const toggleWord =
    phase === 'running'
      ? clock.current.running || !opening
        ? 'Pause'
        : 'Resume'
      : phase === 'paused'
        ? 'Resume'
        : phase === 'ready'
          ? 'Start'
          : 'Again';
  const words: Record<Action, string> = {
    toggle: toggleWord,
    lap: 'Lap',
    finish: 'Finish',
    reset: 'Reset',
  };
  const off: Record<Action, boolean> = {
    toggle: false,
    lap: phase !== 'running',
    finish: phase !== 'running' && phase !== 'paused',
    reset: phase === 'ready',
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onKeyDown={controls ? onKeyDown : undefined}
    >
      <div
        className={styles.stage}
        data-part="stage"
        role={controls ? 'group' : undefined}
        aria-label={
          controls
            ? `${label}: Enter to start or pause, L for a lap, F to finish, R to reset`
            : undefined
        }
        tabIndex={controls ? 0 : undefined}
      >
        <div key={round} className={styles.run}>
          <LivePill
            clock={clock}
            limit={limit}
            mode={mode}
            running={phase === 'running'}
            onFrame={onFrame}
            warn={warn}
            phase={phase}
            beat={beat}
            hard={beat !== undefined && beat <= 3}
            lap={lapCount}
            tag={tag}
            label={`${label}, ${mode === 'up' ? 'counting up' : 'counting down'}`}
          />
          <ol className={styles.splits} data-part="splits" aria-label="Laps">
            {splits.map((s, index) => (
              <li
                key={s.n}
                className={styles.split}
                data-part="split"
                style={{ '--i': index } as CSSProperties}
              >
                <span className={styles.splitName}>Lap {s.n}</span>
                <span className={styles.splitTime}>{formatTime(s.split)}</span>
                {s.delta !== null && (
                  <span
                    className={styles.delta}
                    data-faster={s.delta <= 0 || undefined}
                    aria-label={`${speak(Math.abs(s.delta))} ${s.delta <= 0 ? 'faster' : 'slower'}`}
                  >
                    {s.delta <= 0 ? '−' : '+'}
                    {(Math.abs(s.delta) / 1000).toFixed(2)}
                  </span>
                )}
              </li>
            ))}
          </ol>
        </div>
      </div>
      {controls && (
        <div className={styles.controls} data-part="controls">
          <div className={styles.actions} role="group" aria-label="Timer">
            {BUTTONS.map(({ action, glyph, key, keys }) => (
              <button
                key={action}
                type="button"
                className={styles.action}
                data-part="action"
                aria-keyshortcuts={keys}
                aria-disabled={off[action] || undefined}
                title={`${words[action]} (${key})`}
                onClick={() => {
                  if (!off[action]) input(action);
                }}
              >
                <span className={styles.glyph} data-part="glyph" aria-hidden="true">
                  {glyph}
                </span>
                {words[action]}
              </button>
            ))}
          </div>
          <div className={styles.modes} role="group" aria-label="Count">
            {(['up', 'down'] as const).map((m) => (
              <button
                key={m}
                type="button"
                className={styles.mode}
                data-part="mode"
                aria-pressed={mode === m}
                onClick={() => switchMode(m)}
              >
                {m === 'up' ? 'Count up' : 'Count down'}
              </button>
            ))}
          </div>
        </div>
      )}
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### race-timer/race-timer.module.css

```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;
  --red: #f15050;
  --red-glow: rgb(241 80 80 / 0.6);
  --teal: #6bdecc;
  --flash: #fff;
  /* On the stage itself. */
  --rim: rgb(0 0 0 / 0);
  --drop: rgb(0 0 0 / 0.22);
  --focus: #1b1a18;
  --chip: #fff;
  --chip-active: #ececec;
  --chip-line: rgb(0 0 0 / 0.12);
  --chip-ink: #1b1a18;
  --glyph: #2a2926;
  --glyph-ink: #f4f1e9;
  --u: var(--timer-unit, min(1.75px, calc((100cqi - 32px) / 180)));
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  justify-items: center;
  align-content: center;
  gap: 18px;
  box-sizing: border-box;
  width: 100%;
  padding: 24px 16px;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --rim: light-dark(rgb(0 0 0 / 0), rgb(255 255 255 / 0.09));
    --drop: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.5));
    --focus: light-dark(#1b1a18, #fff);
    --chip: light-dark(#fff, #222120);
    --chip-active: light-dark(#ececec, #2f2e2c);
    --chip-line: light-dark(rgb(0 0 0 / 0.12), rgb(255 255 255 / 0.12));
    --chip-ink: light-dark(#1b1a18, #ecebe7);
    --glyph: light-dark(#2a2926, #e2ded3);
    --glyph-ink: light-dark(#f4f1e9, #1b1a18);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.stage {
  display: grid;
  place-items: center;
  width: 100%;
  border-radius: 14px;
  outline: none;
}
.stage:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}
.run {
  display: grid;
  justify-items: center;
  width: 100%;
  padding: calc(4 * var(--u)) 0;
}

/* ---- The pill and its tag ---- */

.pillBox {
  display: grid;
  justify-items: center;
  font-family: inherit;
}
.tagRow {
  display: grid;
  align-items: end;
  justify-items: center;
  height: calc(26 * var(--u));
  margin-bottom: calc(7 * var(--u));
}
.tag {
  display: flex;
  align-items: center;
  gap: calc(6 * var(--u));
  box-sizing: border-box;
  height: calc(19 * var(--u));
  margin: 0;
  padding: 0 calc(9 * var(--u));
  border-radius: calc(9.5 * var(--u));
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--rim),
    0 calc(1 * var(--u)) calc(3 * var(--u)) var(--drop);
  color: var(--ink);
  font-size: calc(12 * var(--u));
  font-style: italic;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  animation: tagIn 420ms 120ms cubic-bezier(0.2, 0.9, 0.3, 1.3) backwards;
}
.tag .quill {
  width: calc(11 * var(--u));
  height: calc(5.1 * var(--u));
}
.tag .quillInk {
  fill: var(--ink);
  opacity: 0.85;
}
.tag[data-kind='record'] .tagWord {
  color: var(--teal);
}
.tag[data-kind='timeup'] .tagWord {
  color: var(--red);
}
.tagWord {
  display: block;
  padding-right: 0.08em;
}

.pill {
  position: relative;
  box-sizing: border-box;
  width: calc(159 * var(--u));
  height: calc(31 * var(--u));
  overflow: hidden;
  border-radius: calc(15.5 * var(--u));
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--rim),
    0 calc(1 * var(--u)) calc(3 * var(--u)) var(--drop);
  color: var(--ink);
  animation: pop 380ms cubic-bezier(0.2, 0.9, 0.3, 1.25) backwards;
}
.digits {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding-top: calc(0.5 * var(--u));
  font-size: calc(20 * var(--u));
  font-style: italic;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  letter-spacing: -0.04em;
  white-space: nowrap;
}
.pill[data-warn] .digits {
  color: var(--red);
}
.digits[data-beat] {
  animation: throb 420ms cubic-bezier(0.2, 0.9, 0.3, 1);
}
.digits[data-beat][data-hard] {
  animation: throbHard 460ms cubic-bezier(0.2, 0.9, 0.3, 1);
}
.pill[data-phase='paused'] .digits {
  animation: blink 1s steps(1) infinite;
}
.pill[data-phase='finished'] .digits {
  animation: finishBlink 900ms 80ms linear;
}

/* The quills on the caps. */
.cap {
  position: absolute;
  top: 50%;
  display: block;
  width: calc(15 * var(--u));
  height: calc(7 * var(--u));
  margin-top: calc(-3.4 * var(--u));
  transition: translate 300ms cubic-bezier(0.2, 0.9, 0.3, 1.3);
}
.cap[data-side='left'] {
  --out: -1;
  left: calc(9.5 * var(--u));
  animation: capIn 300ms 140ms cubic-bezier(0.2, 0.9, 0.3, 1.15) backwards;
}
.cap[data-side='right'] {
  --out: 1;
  right: calc(9.5 * var(--u));
  animation: capIn 300ms 140ms cubic-bezier(0.2, 0.9, 0.3, 1.15) backwards;
}
.cap[data-flick] {
  animation: flick 360ms cubic-bezier(0.2, 0.9, 0.3, 1);
}
.pill[data-phase='finished'] .cap {
  translate: calc(var(--out) * 3 * var(--u)) 0;
}
.quill {
  display: block;
  width: calc(15 * var(--u));
  height: calc(7 * var(--u));
  overflow: visible;
}
.quill[data-side='right'] {
  scale: -1 1;
}
.quillInk {
  fill: var(--quill);
}

/* The warning's red breath inside the pill, the lap's sheen, the finish flash. */
.glow,
.sheen,
.pill::after {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
}
.glow {
  opacity: 0;
  box-shadow: inset 0 0 calc(9 * var(--u)) calc(1 * var(--u)) var(--red-glow);
}
.glow[data-beat] {
  animation: breathe 900ms ease-out;
}
.glow[data-hard] {
  animation: breathe 900ms ease-out;
  box-shadow: inset 0 0 calc(12 * var(--u)) calc(2 * var(--u)) var(--red-glow);
}
.pill[data-phase='timeup'] .glow {
  animation: breathe 1200ms ease-out;
  box-shadow: inset 0 0 calc(12 * var(--u)) calc(2 * var(--u)) var(--red-glow);
}
.sheen {
  opacity: 0;
  background: linear-gradient(
    100deg,
    transparent 30%,
    rgb(255 255 255 / 0.22) 47%,
    rgb(255 255 255 / 0.34) 50%,
    rgb(255 255 255 / 0.22) 53%,
    transparent 70%
  );
  background-size: 250% 100%;
}
.sheen[data-on] {
  animation: sweep 560ms cubic-bezier(0.3, 0, 0.3, 1);
}
.pill::after {
  content: '';
  opacity: 0;
  background: var(--flash);
}
.pill[data-phase='finished']::after {
  animation: flash 420ms ease-out;
}
.pill[data-phase='finished'] .sheen {
  animation: sweep 700ms 60ms cubic-bezier(0.3, 0, 0.3, 1);
}
.pill[data-phase='timeup'] {
  animation: shake 440ms linear;
}

/* ---- The splits ---- */

.splits {
  position: relative;
  width: 100%;
  height: calc(75 * var(--u));
  margin: calc(9 * var(--u)) 0 0;
  padding: 0;
  list-style: none;
}
.split {
  position: absolute;
  top: calc(var(--i) * 25 * var(--u));
  left: 50%;
  display: flex;
  align-items: center;
  gap: calc(7 * var(--u));
  box-sizing: border-box;
  height: calc(20 * var(--u));
  padding: 0 calc(10 * var(--u));
  border-radius: calc(10 * var(--u));
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--rim),
    0 calc(1 * var(--u)) calc(3 * var(--u)) var(--drop);
  color: var(--ink);
  font-style: italic;
  line-height: 1;
  white-space: nowrap;
  translate: -50% 0;
  transition: top 280ms cubic-bezier(0.2, 0.9, 0.3, 1.1);
  animation: drop 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2) backwards;
}
/* Older splits dim their words, not their plate, so it keeps its footing on a light page. */
.split > * {
  opacity: calc(1 - var(--i) * 0.3);
  transition: opacity 280ms ease;
}
.splitName {
  color: var(--muted);
  font-size: calc(10.5 * var(--u));
  font-weight: 500;
}
.splitTime {
  font-size: calc(13 * var(--u));
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.delta {
  color: var(--red);
  font-size: calc(11 * var(--u));
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.delta[data-faster] {
  color: var(--teal);
}

/* ---- The playground's buttons ---- */

.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 8px 10px;
}
.actions,
.modes {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
}
.action,
.mode {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 36px;
  padding: 0 12px 0 5px;
  border: 0;
  border-radius: 999px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-line);
  color: var(--chip-ink);
  font: inherit;
  font-size: 14px;
  font-style: italic;
  font-weight: 600;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
  transition:
    background-color 120ms ease,
    opacity 120ms ease,
    scale 120ms ease;
}
.mode {
  padding: 0 12px;
}
.action:active {
  scale: 0.96;
}
.action[aria-disabled='true'] {
  opacity: 0.45;
  cursor: default;
}
.action[aria-disabled='true']:active {
  scale: none;
}
.mode[aria-pressed='true'] {
  background: var(--chip-active);
  box-shadow: inset 0 0 0 2px var(--chip-ink);
}
.action:focus-visible,
.mode:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.glyph {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--glyph);
  color: var(--glyph-ink);
  font-size: 13px;
  font-style: normal;
  font-weight: 800;
}

.live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ---- Keyframes ---- */

@keyframes pop {
  from {
    opacity: 0;
    scale: 0.88;
  }
  60% {
    opacity: 1;
  }
}
@keyframes capIn {
  from {
    opacity: 0;
    translate: calc(var(--out) * -12 * var(--u)) 0;
  }
}
@keyframes flick {
  35% {
    transform: translateX(calc(var(--out) * 3.5 * var(--u)));
  }
}
@keyframes tagIn {
  from {
    opacity: 0;
    translate: 0 calc(8 * var(--u));
    scale: 0.92;
  }
}
@keyframes throb {
  35% {
    scale: 1.07;
  }
}
@keyframes throbHard {
  30% {
    scale: 1.14;
  }
}
@keyframes breathe {
  20% {
    opacity: 1;
  }
}
@keyframes sweep {
  from {
    opacity: 1;
    background-position: 100% 0;
  }
  to {
    opacity: 1;
    background-position: 0 0;
  }
}
@keyframes flash {
  15% {
    opacity: 0.35;
  }
}
@keyframes blink {
  50% {
    opacity: 0.3;
  }
}
@keyframes finishBlink {
  0%,
  33%,
  66% {
    opacity: 1;
  }
  16%,
  50%,
  83% {
    opacity: 0.25;
  }
}
@keyframes shake {
  12% {
    translate: calc(-5 * var(--u)) 0;
  }
  28% {
    translate: calc(4 * var(--u)) 0;
  }
  44% {
    translate: calc(-3 * var(--u)) 0;
  }
  60% {
    translate: calc(2 * var(--u)) 0;
  }
  76% {
    translate: calc(-1 * var(--u)) 0;
  }
}
@keyframes drop {
  from {
    opacity: 0;
    transform: translateY(calc(-10 * var(--u)));
  }
}

@media (prefers-reduced-motion: reduce) {
  .pill,
  .cap,
  .cap[data-flick],
  .tag,
  .split,
  .digits[data-beat],
  .glow[data-beat],
  .sheen[data-on],
  .pill[data-phase] .digits,
  .pill[data-phase] .glow,
  .pill[data-phase] .sheen,
  .pill[data-phase]::after,
  .pill[data-phase='timeup'] {
    animation: none;
  }
  .cap,
  .split,
  .action,
  .mode {
    transition: none;
  }
  .pill[data-phase='paused'] .digits {
    opacity: 0.6;
  }
}
```

### race-timer/use-autoplay.ts

```tsx
// 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);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```