useCountdown
A duration-based countdown state machine — seconds-granularity ticking with start/pause/reset and a once-only onComplete.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-countdown.jsonPrompt
Build a React + TypeScript "useCountdown" hook (no dependencies beyond React;
remaining is derived from a Date.now() deadline, woken by a self-renewing
setTimeout — not a decrementing setInterval).
Contract
- `useCountdown(seconds: number, options?: { autoStart?: boolean; onComplete?:
() => void }): { remaining: number; running: boolean; start: () => void;
pause: () => void; reset: () => void }`.
- `autoStart` defaults to false. `remaining` starts equal to `seconds`.
`autoStart` with `seconds <= 0` starts not running — there is nothing to
count.
- `start`, `pause`, and `reset` are referentially stable across re-renders
(wrapped in `useCallback` with empty dependency arrays) so they can be
passed straight to `onClick` or dropped into a dependency array.
Behavior
- Deadline, not decrement: on every entry into the running state, anchor
`deadline = Date.now() + remaining * 1000` (kept in a ref, null while not
running). Every wake-up recomputes `ceil((deadline - now) / 1000)` instead
of subtracting 1, because browsers throttle a background tab's timers to
about once a minute and a decrementing counter under-counts: leave for 60s
and it would still claim 45 to go.
- Wake-ups are a self-renewing `setTimeout` aimed at the next whole-second
boundary (`left - (next - 1) * 1000`, floored at 16ms), so there is no
accumulated drift and no number shown twice because of a few ms of skew.
Each wake-up clears any pending timeout first, so exactly one chain is
alive.
- A `visibilitychange` listener recomputes the moment the tab becomes
visible again, so a returning user never waits for the next throttled tick.
- Reaching 0 clears the deadline, stops the run (`running` flips to `false`)
and calls `onComplete` exactly once. `remaining` clamps at 0.
- `onComplete` is read through a ref updated on every render (the classic
latest-ref pattern), so passing a fresh inline arrow closing over changing
state does not rebuild or restart the timer.
- A "has this run already completed" flag guards `onComplete` so it fires
once per run. `reset()` clears that flag, re-arming it for the next run.
- `pause()` settles the deadline back into a duration first
(`ceil((deadline - now) / 1000)`), then clears the deadline and the timer.
`remaining` is frozen at that value; nothing ticks while paused, and the
next `start()` re-anchors a new deadline from it.
- `reset()` stops the timer, clears the deadline and sets `remaining` back to
the current `seconds` argument (read through a ref, so it reflects the
latest value the caller passed in, not a stale mount-time snapshot), and
re-arms `onComplete`. It writes the remaining ref synchronously, so
`reset(); start()` in one handler restarts a full run.
- `start()` (re)starts ticking from the current `remaining`. If `remaining`
is already 0, it is a no-op until `reset()` runs — calling `start()` alone
never causes `remaining` to go negative or `onComplete` to refire. When no
deadline is live (after `reset()` / `pause()` in the same handler) it also
bumps a run token in the timer effect's dependencies: `running` stays true
across that batch, and the token is what rebuilds the timer.
- The current remainder is kept in a ref, not in the timer effect's
dependency array (which is `[running, runToken]`) — otherwise the timer
would be torn down and rebuilt on every tick. Every `setState` call happens
inside a timer or event callback, never synchronously in the effect body.
- The timeout and the visibility listener are removed on unmount and on every
`running` / run-token change through the same effect's cleanup, so there is
never more than one live timer and nothing fires after unmount.
- Changing the `seconds` argument mid-run does NOT retroactively change the
in-flight `remaining` — it only changes what a subsequent `reset()` reverts
to. Keep this intentional and documented rather than surprising.
Rendering & styling
- The hook renders nothing. Consumers own all UI: a `tabular-nums` font on
any digits so they don't shift width every tick, semantic tokens for state
(`bg-primary` for a running indicator, `bg-muted` track, `text-muted-
foreground` for idle/disabled labels), and a real `disabled` state on the
action button while `running` (or while depleted, for start).
- Any progress bar or pulse driven by the tick is decorative: respect
`prefers-reduced-motion` (`motion-reduce:transition-none`) and keep the
underlying number readable without it.
Customization levers
- `autoStart` — on for a cooldown that should begin the moment the component
mounts (OTP resend right after sending the first code); off when the user
triggers the first run explicitly.
- `onComplete` — hook side effects here (enable a button, show a toast,
advance a step) instead of polling `remaining === 0` in a `useEffect`.
- Millisecond granularity — this hook intentionally ticks in whole seconds;
a variant could accept `msRemaining` and tick every 100ms for a visibly
smoother progress bar, at the cost of more re-renders.
- Formatting helpers — `remaining` is a raw integer on purpose; add a
`mm:ss` or `Resend in Ns` formatter at the call site rather than baking a
format into the hook, since every consumer wants a different string.
- `reset(newSeconds?)` — the default `reset()` keeps the contract minimal by
reverting to the current `seconds` argument; a consumer that wants a one-
off override independent of that argument can extend `reset` to accept an
explicit value instead of re-rendering with a new `seconds` first.Concepts
- Duration countdown, not target countdown — this hook counts down a plain number of seconds handed to it (
useCountdown(60)); it has no notion of "today at 6pm". The siblingCountdowncomponent owns target-time math and day/hour/minute/second derivation — pick this hook for OTP/exam/promo timers and the component for calendar-style countdowns, never both for the same job. - Pause freezes, it doesn't fast-forward or reset —
pause()clears the underlying timer soremainingsits exactly where it was; resuming withstart()continues from that frozen value instead of losing or replaying elapsed time. - Once-only completion, re-armed by reset —
onCompleteis guarded by an internal flag so a run that reaches 0 notifies exactly once, even under React Strict Mode's double-invoked effects; callingreset()clears that flag so the next full run can complete again. - Latest-ref callback —
onComplete(and thesecondsargument used byreset) are read through refs updated every render, so an inline arrow function that closes over changing component state never causes the timer to be torn down and rebuilt mid-countdown. - Stable controls —
start,pause, andresetkeep the same function identity for the lifetime of the component, so they're safe to pass directly asonClickhandlers or list in auseEffectdependency array without triggering extra runs.
useDocumentTitle
A hook that syncs document.title to a value for as long as a component is mounted, restoring the pre-mount title on unmount.
useFullscreen
A hook that puts a single element into and out of the browser's native fullscreen mode, keeping a `fullscreen` boolean in sync — including when the user presses Esc.