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.
Installation
npx shadcn@latest add https://craftbits.dev/r/use-countdown-timer.jsonNo 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
| Field | Type | Description |
|---|---|---|
durationMs | number | Total countdown duration. The timer starts at this value and ticks down to 0. Changing the value resets the countdown. |
autoStart | boolean | Begin running automatically on mount. Defaults to false. |
onComplete | (completedAt: number) => void | Fires once when remainingMs first reaches zero. Receives Date.now() at completion. |
tickMs | number | Interval between ticks. Defaults to 100. Drop to 16 for frame-rate updates or raise to 1000 for clock-style 1 Hz ticks. |
Return value
| Field | Type | Description |
|---|---|---|
remainingMs | number | Milliseconds remaining until completion. Clamped to the range from 0 to durationMs. |
running | boolean | true while the countdown is actively ticking. |
start | () => void | Begin (or resume) the countdown from its current remainingMs. If already complete, restart from durationMs. |
pause | () => void | Pause without resetting remainingMs. |
reset | () => void | Reset remainingMs back to durationMs and pause. |
Behaviour
- Drift-free ticks. The hook anchors an end timestamp at
starttime and recomputesremainingMsfromDate.now()on every tick. A throttled background tab can drop the tick rate without skewing the displayed remaining time once the tab refocuses. - Single-fire
onComplete. The callback runs once per countdown — re-runs are gated by an internal flag that resets onstart(after a complete) or onreset. - Duration-as-reset. Changing
durationMsresetsremainingMsto the new total and respectsautoStartfor the next cycle. Pass a stable value to avoid accidental restarts. - SSR-safe. No
windowaccess at module scope; the interval is only scheduled insideuseEffect.
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-numsto any element displayingremainingMsso digits don't jitter as they change width. role="timer"+aria-live="off". Announcing every tick is hostile to screen-reader users. Usearia-live="off"on the live tick display and surface a singlearia-live="polite"announcement on theonCompletecallback instead.- Reduced motion. A purely numeric countdown is fine under
prefers-reduced-motion. If you pair it with a ring or progress animation, checkusePrefersReducedMotionand 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 —localStoragepersistence, warning-tier thresholds, dismiss state, and project-specificlabel/type/alertfields. 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.