Display
Countdown
A ticking days/hours/minutes/seconds countdown for launches and promotions, with a one-shot onComplete callback and SSR-safe hydration.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/countdown.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Countdown" component, zero runtime
dependencies beyond React.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: target (string | number | Date — the moment to count down to),
onComplete?: () => void (fires exactly once, the instant remaining time
reaches zero), labels?: { days?, hours?, minutes?, seconds? } (English
defaults, override any subset for i18n), hideZeroDays?: boolean (default
true — hides the days cell while the target is under 24h away).
className merges onto the root via cn().
Behavior
- Normalize target to an epoch-ms number (Date instances via getTime(),
strings/numbers via new Date()). Split the remaining milliseconds into
days/hours/minutes/seconds once per second.
- Render purity: never read Date.now() during render. Keep the split
result in state, seeded to null. Before the first client tick (which
covers the server-rendered markup and the first client paint), render
"--" placeholders instead of a computed value — reading Date.now()
during render would both violate render purity and desync the SSR
markup from what the client would compute a moment later.
- A single effect (keyed only on the normalized target-ms number) does
all the ticking: it schedules a requestAnimationFrame for the very
first real value (arrives next frame instead of waiting out a full 1s
interval delay) and a setInterval(tick, 1000) for every subsequent
second; state is only ever set from inside those two async callbacks.
When remaining time hits zero, clear the interval and call onComplete
exactly once, guarded by a ref flag — never included in the effect's
dependency array, so an inline (non-memoized) onComplete passed by the
consumer can't restart the interval on every parent re-render. Read the
latest onComplete through a ref that's synced in its own no-deps effect
(ref writes belong in effects, not render).
Rendering & styling
- Semantic tokens only: bg-muted for each digit cell, text-muted-foreground
for the unit labels, cn() merging the consumer className onto the root.
- Layout: a flex gap-3 row of digit groups (no separator glyph between
them — each cell's own box is the separator). Each group stacks a digit
cell above its label (flex flex-col items-center gap-1).
- Digit cell: text-3xl font-semibold bg-muted rounded-lg px-3 py-2,
tabular-nums so digits don't shift width against each other, plus a
min-w-[2ch] floor so "--"/single digits/two digits all reserve the same
box width — no layout jitter as seconds roll from "9" to "10".
- Label: text-xs text-muted-foreground uppercase tracking-wide underneath.
- Digit flip: key each digit cell by its own value so React remounts the
node on every change, and play a short hoisted @keyframes (opacity 0 +
translateY(0.35em) -> opacity 1 + translateY(0), ~300ms) via a React 19
<style href precedence> tag. Skip the animation under
prefers-reduced-motion (read via useSyncExternalStore on matchMedia) —
the digit still updates every second, it just doesn't play the slide.
- Accessibility: root gets role="timer" and aria-live="off" so the value
is available to assistive tech on demand without spamming an
announcement every second (a live countdown updating once a second
worth of text nodes would otherwise be read out loud constantly).
Customization levers
- Cell style: swap bg-muted/rounded-lg for a bordered, gradient, or flat
numeral treatment — it's one className on the digit span.
- Flip intensity: tune the @keyframes translateY distance and duration,
or drop the animation for an instant swap while keeping the key-remount
structure (useful under a stricter motion budget than
prefers-reduced-motion alone covers).
- Server-time calibration: this component trusts the client clock as-is;
to correct for client/server clock drift, compute an offsetMs once
(serverNowMs - Date.now() at page load) and pass target adjusted by
that offset, or add an offset prop that's added inside the tick
calculation before splitting.
- Completion slot: onComplete is a signal, not a renderer — swap the
Countdown for a "Sale ended" / "Launched!" element in the parent's own
state on fire, rather than teaching Countdown a "done" render branch.Concepts
- Tick state, not derived render — the days/hours/minutes/seconds split lives in
useState, recomputed once perrequestAnimationFrame/setIntervalcallback; render itself never callsDate.now(), keeping it a pure function of props and state. - SSR placeholder hydration — the server (and the client's very first paint, before any effect has run) has no clock to read without breaking purity, so both render
"--"in every cell; the real value appears the instant the first tick effect fires, with no mismatch warning. - One-shot completion, ref-guarded — a boolean ref (not state) tracks whether
onCompletealready fired, so re-renders or prop churn can never trigger it twice; a freshtargetresets the guard because the tick effect is keyed ontargetMsand re-initializes it. - Latest-ref for the callback —
onCompleteis read through a ref synced in its own dependency-less effect rather than closed over directly, so the main ticking effect only restarts whentargetMsitself changes — not on every parent render that recreates an inline handler. - Digit flip via remount — each cell is keyed by its own current value, so a change swaps in a fresh DOM node and the CSS keyframe plays on mount;
prefers-reduced-motionkeeps the key stable so the same node persists and the number swaps in place with no motion.