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;
uses setInterval only, no Date.now()-based timestamp math).
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`.
- `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
- While `running`, `remaining` decrements by 1 every 1000ms. Reaching 0 stops
the timer (`running` flips to `false`) and calls `onComplete` exactly once.
The interval never goes negative — it 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 interval.
- 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()` clears the interval — it does not let it keep running and discard
ticks. `remaining` is frozen exactly where it was; nothing decrements while
paused.
- `reset()` stops the timer 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`.
- `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.
- The decrementing value the interval reads from is kept in a ref, not in the
interval-creating effect's dependency array — otherwise the timer would be
torn down and rebuilt on every single tick. Every `setState` call happens
inside the `setInterval` callback (an async boundary), never synchronously
in the effect body itself.
- The interval is cleared on unmount and on every `running` transition
(pause, reset, auto-stop at 0, start) through the same effect's cleanup, so
there is never more than one live timer.
- 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 interval 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.