useCountdownTimer

A headless countdown. Pass durationMs and the hook ticks remainingMs from that total down to 0, exposing running plus imperative start / pause / reset so the consumer drives the controls (a button, a keyboard shortcut, a useEffect) and the rendering (a clock face, a ring, a progress bar).

Drift-free by construction: the hook stores the end timestamp once, then recomputes remainingMs from Date.now() on every tick — a throttled background tab can't desynchronise the display from wall-clock time.

5.0s
idle
Customize
Duration
5000 ms
Tick
100 ms
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/use-countdown-timer.json

No external dependencies. setInterval and Date.now() ship in every JavaScript runtime.

Usage

"use client";
import { useCountdownTimer } from "@craft-bits/core";
 
export function Pomodoro() {
  const { remainingMs, running, start, pause, reset } = useCountdownTimer({
    durationMs: 25 * 60 * 1000,
    onComplete: () => playChime(),
  });
  const seconds = Math.ceil(remainingMs / 1000);
  return (
    <div>
      <div>{seconds}s</div>
      <button onClick={running ? pause : start}>
        {running ? "Pause" : "Start"}
      </button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Auto-start on mount:

const { remainingMs } = useCountdownTimer({
  durationMs: 10000,
  autoStart: true,
});

API

Options

FieldTypeDescription
durationMsnumberTotal countdown duration. The timer starts at this value and ticks down to 0. Changing the value resets the countdown.
autoStartbooleanBegin running automatically on mount. Defaults to false.
onComplete(completedAt: number) => voidFires once when remainingMs first reaches zero. Receives Date.now() at completion.
tickMsnumberInterval between ticks. Defaults to 100. Drop to 16 for frame-rate updates or raise to 1000 for clock-style 1 Hz ticks.

Return value

FieldTypeDescription
remainingMsnumberMilliseconds remaining until completion. Clamped to the range from 0 to durationMs.
runningbooleantrue while the countdown is actively ticking.
start() => voidBegin (or resume) the countdown from its current remainingMs. If already complete, restart from durationMs.
pause() => voidPause without resetting remainingMs.
reset() => voidReset remainingMs back to durationMs and pause.

Behaviour

  1. Drift-free ticks. The hook anchors an end timestamp at start time and recomputes remainingMs from Date.now() on every tick. A throttled background tab can drop the tick rate without skewing the displayed remaining time once the tab refocuses.
  2. Single-fire onComplete. The callback runs once per countdown — re-runs are gated by an internal flag that resets on start (after a complete) or on reset.
  3. Duration-as-reset. Changing durationMs resets remainingMs to the new total and respects autoStart for the next cycle. Pass a stable value to avoid accidental restarts.
  4. SSR-safe. No window access at module scope; the interval is only scheduled inside useEffect.

Examples

Toast auto-dismiss

const { remainingMs, start, reset } = useCountdownTimer({
  durationMs: 4000,
  onComplete: () => setOpen(false),
});
 
const onShow = () => {
  reset();
  start();
};

Quiz time-limit

const { remainingMs, running, start } = useCountdownTimer({
  durationMs: 30000,
  autoStart: true,
  onComplete: () => submitAnswer(),
});

Pomodoro with pause

const { remainingMs, running, start, pause } = useCountdownTimer({
  durationMs: 25 * 60 * 1000,
});

Bind one button to running ? pause : start and the label flips with state.

Props

useCountdownTimer takes a single options object — see Options above.

Accessibility

useCountdownTimer is a low-level hook with no DOM surface, but how the consumer renders the countdown matters:

  • Tabular numbers. Apply font-variant-numeric: tabular-nums to any element displaying remainingMs so digits don't jitter as they change width.
  • role="timer" + aria-live="off". Announcing every tick is hostile to screen-reader users. Use aria-live="off" on the live tick display and surface a single aria-live="polite" announcement on the onComplete callback instead.
  • Reduced motion. A purely numeric countdown is fine under prefers-reduced-motion. If you pair it with a ring or progress animation, check usePrefersReducedMotion and short-circuit the visual.

Credits

  • Extracted from: terminal-dreams (src/components/cookbook/CookbookTimerProvider.tsx). The source was a React context provider managing a list of named timers backed by a separate timer-engine module — localStorage persistence, warning-tier thresholds, dismiss state, and project-specific label / type / alert fields. The library variant strips the provider, the persistence, the multi-timer collection, and every cookbook-specific concept down to one drift-free countdown so it composes by hand into rings, progress bars, or notification trays.