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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Digit flip: new value settles in with a small upward slide. React 19 hoisted style. */
const KEYFRAMES = `@keyframes cd-flip{from{opacity:0;transform:translateY(0.35em)}to{opacity:1;transform:translateY(0)}}`

export interface CountdownLabels {
  days?: string
  hours?: string
  minutes?: string
  seconds?: string
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/countdown.json

Prompt

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 per requestAnimationFrame/setInterval callback; render itself never calls Date.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 onComplete already fired, so re-renders or prop churn can never trigger it twice; a fresh target resets the guard because the tick effect is keyed on targetMs and re-initializes it.
  • Latest-ref for the callbackonComplete is read through a ref synced in its own dependency-less effect rather than closed over directly, so the main ticking effect only restarts when targetMs itself 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-motion keeps the key stable so the same node persists and the number swaps in place with no motion.

On This Page