{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "countdown",
  "type": "registry:ui",
  "title": "Countdown",
  "description": "Rolling-digit countdown that sleeps off screen and lands in an ended state.",
  "categories": [
    "dates"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/countdown.tsx",
      "content": "\"use client\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\ntype DateInput = Date | string | number;\ntype Unit = \"day\" | \"hour\" | \"minute\" | \"second\";\n\nconst toTime = (d: DateInput) => (d instanceof Date ? d.getTime() : typeof d === \"number\" ? d : Date.parse(d));\nconst noop = () => () => {};\nconst stamp = () => ({ now: Date.now() });\n\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(\n    noop,\n    () => Intl.DateTimeFormat().resolvedOptions().locale,\n    () => \"en-US\",\n  );\n  return locale ?? detected;\n}\n\nexport type CountdownParts = { days: number; hours: number; minutes: number; seconds: number };\n\n/**\n * Milliseconds left until `to`, ticking on the exact second boundary of the\n * target. While `paused` (off screen, tab hidden) it stops ticking but still\n * wakes once at the end, so completion is never missed. null on the server.\n */\nexport function useCountdown(to: DateInput, { paused = false }: { paused?: boolean } = {}) {\n  const at = toTime(to);\n  const [clock] = useState(stamp);\n  const subscribe = useCallback(\n    (notify: () => void) => {\n      let timer = 0;\n      const tick = () => {\n        window.clearTimeout(timer);\n        clock.now = Date.now();\n        notify();\n        const left = at - clock.now;\n        if (Number.isNaN(left) || left <= 0) return;\n        // Asleep: skip the seconds and wake only for the finish.\n        const next = paused || document.hidden ? left : left % 1000 || 1000;\n        timer = window.setTimeout(tick, next + 2);\n      };\n      tick();\n      document.addEventListener(\"visibilitychange\", tick);\n      return () => {\n        window.clearTimeout(timer);\n        document.removeEventListener(\"visibilitychange\", tick);\n      };\n    },\n    [clock, at, paused],\n  );\n  const now = useSyncExternalStore(\n    subscribe,\n    () => clock.now,\n    () => null,\n  );\n  return useMemo(() => {\n    if (now == null || Number.isNaN(at)) return { remaining: null, parts: null, ended: false };\n    const remaining = Math.max(0, at - now);\n    const total = Math.ceil(remaining / 1000);\n    const parts: CountdownParts = {\n      days: Math.floor(total / 86400),\n      hours: Math.floor((total % 86400) / 3600),\n      minutes: Math.floor((total % 3600) / 60),\n      seconds: total % 60,\n    };\n    return { remaining, parts, ended: remaining === 0 };\n  }, [now, at]);\n}\n\n/** \"2 days, 4 hours, 12 minutes\" for screen readers, to the minute (or ten seconds near the end). */\nfunction spoken(locale: string, p: CountdownParts) {\n  const total = p.days * 86400 + p.hours * 3600 + p.minutes * 60 + p.seconds;\n  const unit = (u: Unit, n: number) => new Intl.NumberFormat(locale, { style: \"unit\", unit: u, unitDisplay: \"long\" }).format(n);\n  const list: string[] =\n    total < 60\n      ? [unit(\"second\", Math.ceil(total / 10) * 10 || total)]\n      : [p.days && unit(\"day\", p.days), p.hours && unit(\"hour\", p.hours), p.minutes && unit(\"minute\", p.minutes)].filter((x): x is string => !!x);\n  return new Intl.ListFormat(locale, { style: \"long\", type: \"unit\" }).format(list);\n}\n\n/** The word for a unit, plural-aware and in the reader's language: \"days\", \"hour\", \"Minuten\". */\nfunction unitWord(locale: string, u: Unit, n: number, display: \"long\" | \"narrow\") {\n  const parts = new Intl.NumberFormat(locale, { style: \"unit\", unit: u, unitDisplay: display }).formatToParts(n);\n  return parts.find((p) => p.type === \"unit\")?.value ?? u;\n}\n\nconst expo = `cubic-bezier(${ease.out.join(\",\")})`;\nconst timing = {\n  transformTiming: { duration: 550, easing: expo },\n  spinTiming: { duration: 550, easing: expo },\n  opacityTiming: { duration: 200, easing: \"ease-out\" },\n};\n\nexport type CountdownProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** When it ends: a Date, a timestamp in ms, or an ISO string. */\n  to: DateInput;\n  /** Tiles with unit labels, or one line of text that sits in a sentence. */\n  variant?: \"block\" | \"compact\";\n  /** Seconds left at which it turns to the warning color, for holds and expiring offers. */\n  warnBelow?: number;\n  /** What replaces the digits once it ends. */\n  ended?: React.ReactNode;\n  /** Called once, when it reaches zero while mounted. */\n  onComplete?: () => void;\n  /** Accessible name, read before the time: \"Sale ends in\". */\n  label?: string;\n  locale?: string;\n};\n\nexport function Countdown({\n  to,\n  variant = \"block\",\n  warnBelow,\n  ended: endedContent = \"Ended\",\n  onComplete,\n  label,\n  locale: localeProp,\n  className,\n  ref,\n  ...rest\n}: CountdownProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const [el, setEl] = useState<HTMLDivElement | null>(null);\n  const [onScreen, setOnScreen] = useState(true);\n  const { parts, ended, remaining } = useCountdown(to, { paused: !onScreen });\n  const [announce, setAnnounce] = useState(false);\n  const wasEnded = useRef<boolean | null>(null);\n\n  const setRefs = useCallback(\n    (node: HTMLDivElement | null) => {\n      setEl(node);\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  // Off screen, the digits stop; they catch up with a roll when scrolled back.\n  useEffect(() => {\n    if (!el) return;\n    const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting));\n    io.observe(el);\n    return () => io.disconnect();\n  }, [el]);\n\n  // onComplete fires on the live transition only, not when mounting already past.\n  useEffect(() => {\n    if (remaining == null) return;\n    if (wasEnded.current === false && ended) {\n      onComplete?.();\n      setAnnounce(true);\n    }\n    wasEnded.current = ended;\n  }, [ended, remaining, onComplete]);\n\n  const warn = warnBelow != null && remaining != null && !ended && remaining <= warnBelow * 1000;\n  const state = remaining == null ? \"pending\" : ended ? \"ended\" : \"running\";\n\n  const numbers = (compact: boolean) => {\n    if (!parts) return null;\n    const units: { u: Unit; n: number; max?: number }[] = [];\n    if (parts.days > 0) units.push({ u: \"day\", n: parts.days });\n    if (!compact || parts.days > 0 || parts.hours > 0) units.push({ u: \"hour\", n: parts.hours, max: 2 });\n    units.push({ u: \"minute\", n: parts.minutes, max: 5 }, { u: \"second\", n: parts.seconds, max: 5 });\n    return units;\n  };\n\n  const flow = (n: number, u: Unit, max: number | undefined, pad: boolean) => (\n    <NumberFlow\n      value={n}\n      locales={locale}\n      format={{ minimumIntegerDigits: pad ? 2 : 1, useGrouping: false }}\n      digits={max != null ? { 1: { max } } : undefined}\n      trend={-1}\n      animated={!reduce}\n      data-unit={u}\n      {...timing}\n    />\n  );\n\n  const running =\n    variant === \"compact\" ? (\n      // NumberFlow's mask is taller than the line; a line-high box keeps it level with the text beside it.\n      <span className=\"inline-flex h-[1lh] items-center\">\n        <span className=\"inline-flex items-baseline whitespace-nowrap\">\n          {(numbers(true) ?? []).map(({ u, n, max }, i, list) => {\n            const prev = list[i - 1]?.u;\n            // \"2d 04:12:09\", \"4:12:09\", \"0:42\": the leading unit is unpadded, the rest are two digits.\n            return (\n              <span key={u} className=\"inline-flex items-baseline\">\n                {prev === \"day\" ? <span className=\"w-[0.3em]\" /> : prev ? <span className=\"px-px text-fg-4\">:</span> : null}\n                {flow(n, u, max, u !== \"day\" && !!prev)}\n                {u === \"day\" && <span className=\"text-fg-3\">{unitWord(locale, \"day\", n, \"narrow\")}</span>}\n              </span>\n            );\n          })}\n          {!parts && <span className=\"text-fg-4\">–:––</span>}\n        </span>\n      </span>\n    ) : (\n      <span className=\"flex items-start gap-1.5\">\n        {(numbers(false) ?? ([\"hour\", \"minute\", \"second\"] as Unit[]).map((u) => ({ u, n: -1, max: undefined }))).map(({ u, n, max }, i) => (\n          <motion.span key={u} layout={!reduce} transition={{ duration: 0.3, ease: ease.inOut }} className=\"flex items-start gap-1.5\">\n            {i > 0 && (\n              <span aria-hidden className=\"flex h-14 items-center text-[22px] text-fg-4\">\n                :\n              </span>\n            )}\n            <span className=\"flex flex-col items-center gap-1.5\">\n              <span\n                className={cn(\n                  \"flex h-14 min-w-14 items-center justify-center rounded-lg border bg-raised px-2 text-[28px] font-medium tracking-[-0.02em] shadow-[var(--shadow)]\",\n                  \"transition-[border-color,color] duration-300\",\n                  warn ? \"border-warning/40 text-warning\" : \"border-line text-fg\",\n                )}\n              >\n                {n < 0 ? <span className=\"text-fg-4\">––</span> : flow(n, u, max, true)}\n              </span>\n              <span className=\"font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">{unitWord(locale, u, n < 0 ? 2 : n, \"long\")}</span>\n            </span>\n          </motion.span>\n        ))}\n      </span>\n    );\n\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: 0.28, ease: ease.out, delay: 0.06 } },\n    exit: reduce\n      ? { opacity: 0, transition: { duration: 0.12 } }\n      : { opacity: 0, scale: 0.98, filter: \"blur(2px)\", transition: { duration: 0.18, ease: ease.in } },\n  };\n\n  return (\n    <div\n      ref={setRefs}\n      role=\"timer\"\n      aria-label={label}\n      data-state={state}\n      data-variant={variant}\n      data-warn={warn || undefined}\n      className={cn(\n        \"grid tabular\",\n        // The block keeps its height when the tiles give way to the ended content.\n        variant === \"compact\" ? \"inline-grid align-baseline\" : \"min-h-[76px] items-center justify-items-center\",\n        variant === \"compact\" && (warn ? \"text-warning\" : \"\"),\n        \"transition-colors duration-300\",\n        className,\n      )}\n      {...rest}\n    >\n      <AnimatePresence initial={false}>\n        {ended ? (\n          <motion.span key=\"ended\" className=\"col-start-1 row-start-1 flex items-center self-center\" {...swap}>\n            {endedContent}\n          </motion.span>\n        ) : (\n          <motion.span key=\"running\" aria-hidden className=\"col-start-1 row-start-1\" {...swap}>\n            <NumberFlowGroup>{running}</NumberFlowGroup>\n          </motion.span>\n        )}\n      </AnimatePresence>\n      {!ended && parts && <span className=\"sr-only\">{spoken(locale, parts)}</span>}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce && typeof endedContent === \"string\" ? endedContent : \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/countdown"
}
