Speed Run Timer

A compact mm:ss stopwatch chip for speed-run and timed-exercise UIs. Counts up from zero, ticks every ~250 ms, surfaces the elapsed time via onTick, and tints amber → red as the optional warnAtMs cap is approached and crossed.

Preview

Installation

npx shadcn@latest add https://craftbits.dev/r/speed-run-timer.json

Usage

Drive the running state from a parent flag and bump runKey to reset:

import { SpeedRunTimer } from "@craft-bits/core";
import { useState } from "react";
 
function Practice() {
  const [running, setRunning] = useState(false);
  const [runKey, setRunKey] = useState(0);
 
  return (
    <div className="flex items-center gap-2">
      <SpeedRunTimer
        running={running}
        runKey={runKey}
        warnAtMs={90_000}
        onTick={(ms) => {
          if (ms >= 90_000) setRunning(false);
        }}
      />
      <button onClick={() => setRunning((r) => !r)}>
        {running ? "Stop" : "Start"}
      </button>
      <button
        onClick={() => {
          setRunning(false);
          setRunKey((k) => k + 1);
        }}
      >
        Reset
      </button>
    </div>
  );
}

Uncontrolled (the chip just renders, parent never toggles it):

<SpeedRunTimer defaultRunning />

Anatomy

  • Chip — a single inline-flex <div role="timer"> rendering the mm:ss string in tabular-nums.
  • Tonenormal (warm warning tint), urgent (red, last 60 s before warnAtMs), critical (red + pulse, last 10 s).
  • Reset — change runKey to wipe elapsed back to zero without changing running.

Props

PropTypeDefaultDescription
runningbooleanControlled running flag. Pair with onRunningChange.
defaultRunningbooleanfalseUncontrolled initial running flag.
onRunningChange(running: boolean) => voidCalled when the running state changes (e.g. tab auto-pause).
runKeystring | numberBumping this value resets the elapsed counter to zero.
onTick(msElapsed: number) => voidFires every ~250 ms while running, with the new elapsed time.
warnAtMsnumberSoft cap. Drives the urgent (≤60 s left) and critical (≤10 s) tints.
format(msElapsed: number) => stringmm:ssOverride the display string.
classNamestringMerged onto the root <div> via cn().

Accessibility

  • Renders as <div role="timer"> with aria-label="Elapsed time: mm:ss" so screen readers can read it on demand without interrupting on every tick (aria-live="off").
  • Auto-pauses when the page becomes hidden (document.visibilitychange) and auto-resumes when it returns — users who context-switch don't get false-positive time-outs.
  • The colored tint is reinforced by an animate-pulse on critical, so urgency is not signaled by color alone.

Credits

  • Extracted from: algoflashcards (src/platform/ui/SpeedRunTimer.tsx). The source was a countdown timer hard-wired to a startedAt / timeLimitMs pair plus the project's useTimers hook and the lucide-react <Timer/> icon. craft-bits inverts it to a stopwatch (counts up, no built-in deadline), drops the icon to keep the component dependency-free, replaces useTimers with a drift-free setTimeout loop, adds tab-hide auto-pause, and exposes running / runKey / onTick so the consumer drives start / stop / reset and decides what counts as "time up".