{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "countdown",
  "type": "registry:ui",
  "title": "Countdown",
  "description": "A countdown to a date with rolling digits, progress rings, localised unit labels and a hydration-safe start.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/countdown.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport type CountdownFormat = \"full\" | \"compact\";\ntype Unit = \"day\" | \"hour\" | \"minute\" | \"second\";\n\nexport interface CountdownProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Moment to count down to: a Date, an ISO string or epoch milliseconds. */\n  target: Date | string | number;\n  /** Large segments with unit labels, or a single inline line. */\n  format?: CountdownFormat;\n  /** Show a seconds segment. */\n  showSeconds?: boolean;\n  /** Progress ring around each segment (full format). */\n  rings?: boolean;\n  /** Roll digits when they change. */\n  roll?: boolean;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** BCP 47 locale for unit labels and the accessible summary. */\n  locale?: string;\n  /** Days that make a full days ring. */\n  dayRange?: number;\n  /** Text shown (and announced) once the countdown completes. */\n  completedText?: string;\n  /** Accessible name of the timer. */\n  label?: string;\n  /** Accent colour (any CSS colour). */\n  accent?: string;\n  /** Called once, after mount, when the remaining time reaches zero (also if the target is already past). */\n  onComplete?: () => void;\n}\n\n/* SSR-safe: renders \"no preference\" on the server and the first client pass, so markup always hydrates. */\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- ticker --------------------------------- */\n\n/**\n * One shared second-aligned clock for every countdown on the page. It only runs while\n * something is subscribed and the tab is visible; the server snapshot is null, so the server\n * (and the hydration pass) render a placeholder and ticking starts after mount.\n */\nconst ticker = (() => {\n  const listeners = new Set<() => void>();\n  let now = 0;\n  let timer = 0;\n  const read = () => Math.floor(Date.now() / 1000);\n  const schedule = () => {\n    window.clearTimeout(timer);\n    timer = window.setTimeout(tick, 1000 - (Date.now() % 1000) + 8);\n  };\n  const tick = () => {\n    now = read();\n    listeners.forEach((l) => l());\n    schedule();\n  };\n  const onVisibility = () => {\n    if (document.hidden) {\n      window.clearTimeout(timer);\n      timer = 0;\n    } else tick();\n  };\n  return {\n    subscribe(listener: () => void) {\n      listeners.add(listener);\n      if (listeners.size === 1) {\n        now = read();\n        document.addEventListener(\"visibilitychange\", onVisibility);\n        if (!document.hidden) schedule();\n      }\n      return () => {\n        listeners.delete(listener);\n        if (!listeners.size) {\n          window.clearTimeout(timer);\n          timer = 0;\n          document.removeEventListener(\"visibilitychange\", onVisibility);\n        }\n      };\n    },\n    // Without subscribers the cached value may be stale, so refresh it lazily.\n    getSnapshot: () => (listeners.size ? now : (now = read())),\n  };\n})();\n\nconst noopSubscribe = () => () => {};\nconst getServerSecond = () => null;\n\n/* --------------------------------- helpers -------------------------------- */\n\nfunction toMs(target: Date | string | number) {\n  const ms = target instanceof Date ? target.getTime() : typeof target === \"number\" ? target : Date.parse(target);\n  return Number.isFinite(ms) ? ms : 0;\n}\n\n/** Localised unit name (\"days\", \"Tage\", \"hari\") taken from Intl's unit formatting. */\nfunction unitName(locale: string, unit: Unit, value: number, display: \"long\" | \"narrow\") {\n  try {\n    const parts = new Intl.NumberFormat(locale, { style: \"unit\", unit, unitDisplay: display }).formatToParts(value);\n    return parts\n      .filter((p) => p.type === \"unit\")\n      .map((p) => p.value)\n      .join(\"\")\n      .trim();\n  } catch {\n    return unit;\n  }\n}\n\nfunction summaryText(locale: string, parts: { unit: Unit; value: number }[]) {\n  const items = parts\n    .filter((p) => p.value > 0)\n    .map((p) => {\n      try {\n        return new Intl.NumberFormat(locale, { style: \"unit\", unit: p.unit, unitDisplay: \"long\" }).format(p.value);\n      } catch {\n        return `${p.value} ${p.unit}s`;\n      }\n    });\n  if (!items.length) return \"\";\n  try {\n    return new Intl.ListFormat(locale, { type: \"conjunction\" }).format(items);\n  } catch {\n    return items.join(\", \");\n  }\n}\n\nconst EASE = [0.22, 1, 0.36, 1] as const;\n\nconst SIZES = {\n  sm: { box: \"size-14\", digits: \"text-xl\", label: \"text-[9px]\" },\n  // Four segments must fit a 320px phone in one row, so md and lg start smaller.\n  md: { box: \"size-14 sm:size-[76px]\", digits: \"text-xl sm:text-3xl\", label: \"text-[9px] sm:text-[10px]\" },\n  lg: { box: \"size-[60px] sm:size-28\", digits: \"text-2xl sm:text-5xl\", label: \"text-[9px] sm:text-[10px]\" },\n} as const;\n\n/** A digit that rolls in from above as the countdown decreases. */\nfunction RollDigit({ char, roll }: { char: string; roll: boolean }) {\n  return (\n    <span className=\"relative inline-block overflow-hidden leading-[1.15]\">\n      <span className=\"invisible\">0</span>\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={char}\n          className=\"absolute inset-0 text-center\"\n          initial={roll ? { y: \"-100%\", opacity: 0 } : false}\n          animate={{ y: \"0%\", opacity: 1 }}\n          exit={roll ? { y: \"100%\", opacity: 0 } : { opacity: 0, transition: { duration: 0 } }}\n          transition={{ duration: roll ? 0.5 : 0, ease: EASE }}\n        >\n          {char}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Digits({ text, roll }: { text: string; roll: boolean }) {\n  return (\n    <span className=\"inline-flex tabular-nums\">\n      {Array.from(text).map((char, i) => (\n        // Keyed from the right so the ones digit keeps its identity when a value loses a digit.\n        <RollDigit key={text.length - i} char={char} roll={roll} />\n      ))}\n    </span>\n  );\n}\n\n/**\n * A countdown to a date with rolling digits, optional progress rings and localised unit labels.\n * Ticks from one shared second-aligned clock, only after mount (so SSR never mismatches), and\n * stays quiet for screen readers: role=\"timer\" with a minute-level summary.\n */\nexport function Countdown({\n  target,\n  format = \"full\",\n  showSeconds = true,\n  rings = true,\n  roll = true,\n  size = \"md\",\n  locale = \"en-US\",\n  dayRange = 30,\n  completedText = \"Time’s up\",\n  label = \"Countdown\",\n  accent = \"#FF7448\",\n  onComplete,\n  className = \"\",\n  style,\n  ...props\n}: CountdownProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const completedRef = React.useRef(false);\n  const [inView, setInView] = React.useState(true);\n\n  // Offscreen countdowns stop re-rendering; the time is recomputed from the clock when they return.\n  const subscribe = inView ? ticker.subscribe : noopSubscribe;\n  const nowSec = React.useSyncExternalStore(subscribe, ticker.getSnapshot, getServerSecond);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const targetSec = Math.floor(toMs(target) / 1000);\n  const mounted = nowSec !== null;\n  const remaining = mounted ? Math.max(0, targetSec - nowSec) : null;\n  const completed = remaining === 0;\n\n  React.useEffect(() => {\n    if (completed && !completedRef.current) {\n      completedRef.current = true;\n      onComplete?.();\n    } else if (!completed && remaining !== null) {\n      completedRef.current = false;\n    }\n  }, [completed, remaining, onComplete]);\n\n  const r = remaining ?? 0;\n  const values: { unit: Unit; value: number; max: number }[] = [\n    { unit: \"day\", value: Math.floor(r / 86400), max: Math.max(1, dayRange) },\n    { unit: \"hour\", value: Math.floor((r % 86400) / 3600), max: 24 },\n    { unit: \"minute\", value: Math.floor((r % 3600) / 60), max: 60 },\n    { unit: \"second\", value: r % 60, max: 60 },\n  ];\n  const shown = showSeconds ? values : values.slice(0, 3);\n\n  // Minute-level summary: changes at most once per minute.\n  const minutesLeft = remaining === null ? null : Math.ceil(remaining / 60);\n  const summary =\n    minutesLeft === null\n      ? \"\"\n      : completed\n        ? completedText\n        : minutesLeft <= 1\n          ? `${summaryText(locale, [{ unit: \"minute\", value: 1 }])} or less remaining`\n          : `${summaryText(locale, [\n              { unit: \"day\", value: Math.floor(minutesLeft / 1440) },\n              { unit: \"hour\", value: Math.floor((minutesLeft % 1440) / 60) },\n              { unit: \"minute\", value: minutesLeft % 60 },\n            ])} remaining`;\n\n  const shouldRoll = roll && !reduceMotion;\n  const sizes = SIZES[size] ?? SIZES.md;\n  const pad = (v: number) => String(v).padStart(2, \"0\");\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"timer\"\n      aria-live=\"off\"\n      aria-atomic=\"true\"\n      aria-label={label}\n      data-completed={completed ? \"\" : undefined}\n      className={`inline-flex flex-col items-center gap-3 text-zinc-900 dark:text-zinc-50 ${className}`}\n      style={{ [\"--cd-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {/* Read on demand by screen readers; never announced every second. */}\n      <span className=\"sr-only\">{mounted ? summary : \"Loading countdown\"}</span>\n\n      {format === \"compact\" ? (\n        <span\n          aria-hidden=\"true\"\n          className={`inline-flex items-baseline gap-2 font-mono text-sm tabular-nums tracking-[0.02em] ${\n            completed ? \"text-[var(--cd-ink)]\" : \"\"\n          } [--cd-ink:color-mix(in_oklab,var(--cd-accent)_78%,#18181b)] dark:[--cd-ink:var(--cd-accent)]`}\n        >\n          {shown.map(({ unit, value }) => (\n            <span key={unit} className=\"inline-flex items-baseline gap-px\">\n              {mounted ? <Digits text={pad(value)} roll={shouldRoll} /> : <span>––</span>}\n              <span className=\"text-zinc-500 dark:text-zinc-400\">{unitName(locale, unit, mounted ? value : 2, \"narrow\")}</span>\n            </span>\n          ))}\n        </span>\n      ) : (\n        <span aria-hidden=\"true\" className=\"flex flex-wrap items-start justify-center gap-2 sm:gap-3\">\n          {shown.map(({ unit, value, max }, i) => {\n            const progress = Math.min(1, value / max);\n            // Key rings by the next-larger total, so a wrap (0 → 59) remounts instead of sweeping backwards.\n            const wrapKey = i === 0 ? \"d\" : Math.floor(r / [86400, 86400, 3600, 60][i]);\n            return (\n              <span key={unit} className=\"flex flex-col items-center gap-2\">\n                <span\n                  className={`relative flex items-center justify-center rounded-full font-semibold tracking-[-0.03em] ${sizes.box} ${sizes.digits} ${\n                    rings ? \"\" : \"rounded-xl border border-zinc-900/10 bg-white/60 dark:border-white/10 dark:bg-white/[0.03]\"\n                  } ${completed ? \"text-[var(--cd-accent)]\" : \"\"}`}\n                >\n                  {rings ? (\n                    <svg viewBox=\"0 0 100 100\" className=\"absolute inset-0 size-full -rotate-90\">\n                      <circle cx=\"50\" cy=\"50\" r=\"46\" fill=\"none\" strokeWidth=\"2\" className=\"stroke-zinc-900/10 dark:stroke-white/10\" />\n                      {mounted ? (\n                        <circle\n                          key={wrapKey}\n                          cx=\"50\"\n                          cy=\"50\"\n                          r=\"46\"\n                          fill=\"none\"\n                          strokeWidth=\"2.5\"\n                          strokeLinecap=\"round\"\n                          pathLength={1}\n                          strokeDasharray=\"1 1\"\n                          className=\"stroke-[var(--cd-accent)]\"\n                          style={{\n                            strokeDashoffset: 1 - progress,\n                            transition: shouldRoll ? \"stroke-dashoffset 1s linear\" : undefined,\n                          }}\n                        />\n                      ) : null}\n                    </svg>\n                  ) : null}\n                  <span className=\"relative\">{mounted ? <Digits text={pad(value)} roll={shouldRoll} /> : \"––\"}</span>\n                </span>\n                <span className={`font-mono uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400 ${sizes.label}`}>\n                  {unitName(locale, unit, mounted ? value : 2, \"long\")}\n                </span>\n              </span>\n            );\n          })}\n        </span>\n      )}\n\n      {/* Always present, so the completion message is announced when it is filled in. */}\n      <span role=\"status\" className=\"sr-only\">\n        {completed ? completedText : \"\"}\n      </span>\n      {completed && completedText ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"inline-flex items-center gap-2 rounded-full border border-[var(--cd-accent)]/40 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 dark:text-zinc-200\"\n        >\n          <span aria-hidden=\"true\" className=\"size-1.5 rounded-full bg-[var(--cd-accent)]\" />\n          {completedText}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/countdown"
}
