{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "countdown",
  "type": "registry:ui",
  "title": "Countdown",
  "description": "A launch countdown with rolling digits that morphs into a live state at zero.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/countdown/countdown.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useImperativeHandle,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type CountdownUnit = \"days\" | \"hours\" | \"minutes\" | \"seconds\";\n\n/** Use a countdown for a fixed moment people wait for: a launch, a live event, or a deadline. */\nexport interface CountdownProps {\n  /**\n   * The moment to count down to. A number is epoch milliseconds, a Date is used as is, and an ISO string with `Z` or an offset\n   * such as `2026-10-06T10:00:00-07:00` is exact. A string without an offset is wall time in `timeZone`, or UTC when no zone is given,\n   * so the result never depends on the visitor's own time zone.\n   */\n  target: Date | number | string;\n  /** IANA zone for a target string without an offset, such as \"America/Los_Angeles\". Daylight saving is resolved for the target date. */\n  timeZone?: string;\n  /** Units to show, largest first. The largest unit absorbs the rest, so hours can pass 24 when days are left out. */\n  units?: CountdownUnit[];\n  /** Large digits with unit labels, or one compact line for banners and table cells. */\n  variant?: \"large\" | \"compact\";\n  /** Shown once the target passes. The digits morph into it. */\n  completeLabel?: ReactNode;\n  /** Fires once, the moment the countdown reaches zero while mounted. It fires on time even when the tab is hidden. */\n  onComplete?: () => void;\n  /** Accessible name, such as \"Keynote starts in\". */\n  label?: string;\n  /** Clock source in epoch milliseconds. Pass a server-synced clock when the visitor's device time cannot be trusted. */\n  clock?: () => number;\n  ref?: Ref<HTMLDivElement>;\n  className?: string;\n}\n\nconst { spring, stagger } = motionTokens;\nconst DEFAULT_UNITS: CountdownUnit[] = [\"days\", \"hours\", \"minutes\", \"seconds\"];\nconst SECONDS: Record<CountdownUnit, number> = { days: 86_400, hours: 3_600, minutes: 60, seconds: 1 };\n/** How far each unit counts before carrying into the next, when a larger unit is shown. */\nconst LIMIT: Record<CountdownUnit, number> = { days: Infinity, hours: 24, minutes: 60, seconds: 60 };\nconst NAMES: Record<CountdownUnit, [string, string, string]> = {\n  days: [\"Days\", \"d\", \"day\"],\n  hours: [\"Hours\", \"h\", \"hour\"],\n  minutes: [\"Minutes\", \"m\", \"minute\"],\n  seconds: [\"Seconds\", \"s\", \"second\"],\n};\nconst MAX_TIMEOUT = 2_147_483_647;\nconst systemClock = () => Date.now();\n\n/** Offset of a zone from UTC at an instant, in milliseconds. */\nfunction zoneOffset(epoch: number, timeZone: string) {\n  const parts = new Intl.DateTimeFormat(\"en-US\", {\n    timeZone,\n    hourCycle: \"h23\",\n    year: \"numeric\",\n    month: \"2-digit\",\n    day: \"2-digit\",\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n    second: \"2-digit\",\n  }).formatToParts(new Date(epoch));\n  const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);\n  return (\n    Date.UTC(get(\"year\"), get(\"month\") - 1, get(\"day\"), get(\"hour\"), get(\"minute\"), get(\"second\")) -\n    Math.floor(epoch / 1000) * 1000\n  );\n}\n\n/** Resolves a countdown target to epoch milliseconds without ever reading the device's own time zone. */\nexport function toEpoch(target: Date | number | string, timeZone?: string) {\n  if (typeof target === \"number\") return target;\n  if (target instanceof Date) return target.getTime();\n  const wall = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?)?$/.exec(target.trim());\n  if (!wall) return Date.parse(target);\n  const utc = Date.UTC(+wall[1]!, +wall[2]! - 1, +wall[3]!, +(wall[4] ?? 0), +(wall[5] ?? 0), +(wall[6] ?? 0));\n  if (!timeZone) return utc;\n  // Two passes settle the offset across a daylight saving change.\n  const guess = utc - zoneOffset(utc, timeZone);\n  return utc - zoneOffset(guess, timeZone);\n}\n\n/** Whole seconds left, split across the units on show. */\nfunction partsFor(seconds: number, units: CountdownUnit[]) {\n  return units.map((unit, index) => ({\n    unit,\n    value: index === 0 ? Math.floor(seconds / SECONDS[unit]) : Math.floor(seconds / SECONDS[unit]) % LIMIT[unit],\n  }));\n}\n\n/** Wheel sizes per column: a tens column that only ever shows 0 to 5 turns on a six-digit wheel, so 00 → 59 is a single step. */\nfunction basesFor(unit: CountdownUnit, largest: boolean, length: number) {\n  return Array.from({ length }, (_, index) => {\n    const tens = index === length - 2 && length === 2 && !largest;\n    return tens ? (unit === \"hours\" ? 3 : 6) : 10;\n  });\n}\n\nconst subscribeNothing = () => () => {};\nconst subscribeVisibility = (notify: () => void) => {\n  document.addEventListener(\"visibilitychange\", notify);\n  return () => document.removeEventListener(\"visibilitychange\", notify);\n};\n\n/** One digit on a wheel. Its offset from the wheel position decides where it sits and how clearly it shows. */\nfunction Glyph({ position, digit, base }: { position: MotionValue<number>; digit: number; base: number }) {\n  const offset = useTransform(position, (current) => ((((digit - current) % base) + base * 1.5) % base) - base / 2);\n  const y = useTransform(offset, (current) => `${current}em`);\n  const opacity = useTransform(offset, (current) => Math.max(0, 1 - Math.abs(current)));\n  const visibility = useTransform(offset, (current) => (Math.abs(current) >= 1 ? \"hidden\" : \"visible\"));\n  const filter = useTransform(offset, (current) =>\n    Math.abs(current) < 0.02 || Math.abs(current) >= 1\n      ? \"none\"\n      : `blur(${(Math.abs(current) * motionTokens.blur.subtle).toFixed(2)}px)`,\n  );\n  return (\n    <motion.span\n      className=\"absolute inset-x-0 inset-y-(--feather) text-center\"\n      style={{ y, opacity, filter, visibility }}\n    >\n      {digit}\n    </motion.span>\n  );\n}\n\n/** A digit wheel that takes the short way round, so a countdown ticks downward and a wrap such as 00 → 59 is one step. */\nfunction Wheel({ digit, base, delay, reduced }: { digit: number; base: number; delay: number; reduced: boolean }) {\n  const position = useMotionValue(0);\n  const wheel = useRef({ digit: 0, target: 0, revealed: false });\n  useEffect(() => {\n    const state = wheel.current;\n    if (state.digit === digit) {\n      state.revealed = true;\n      return;\n    }\n    const up = (digit - state.digit + base) % base,\n      down = base - up;\n    state.target += down <= up ? -down : up;\n    state.digit = digit;\n    const first = !state.revealed;\n    state.revealed = true;\n    // A new animation on the same value takes over from the one in flight, keeping its velocity; no cleanup, so a re-run never strands a wheel mid-turn.\n    if (reduced) position.jump(state.target);\n    else animate(position, state.target, first ? { ...spring.smooth, delay } : spring.snappy);\n  }, [base, delay, digit, position, reduced]);\n  return (\n    <motion.span\n      className=\"relative -my-(--feather) inline-block overflow-hidden py-(--feather) [--feather:.16em] [mask-image:linear-gradient(to_bottom,transparent,#000_calc(var(--feather)_*_1.5),#000_calc(100%_-_var(--feather)_*_1.5),transparent)]\"\n      initial={{ width: 0, opacity: 0 }}\n      animate={{ width: \"auto\", opacity: 1 }}\n      exit={{ width: 0, opacity: 0 }}\n      transition={reduced ? { duration: 0 } : spring.morph}\n    >\n      <span className=\"invisible\">0</span>\n      {Array.from({ length: base }, (_, value) => (\n        <Glyph key={value} position={position} digit={value} base={base} />\n      ))}\n    </motion.span>\n  );\n}\n\nexport function Countdown({\n  target,\n  timeZone,\n  units = DEFAULT_UNITS,\n  variant = \"large\",\n  completeLabel = \"Live now\",\n  onComplete,\n  label = \"Time remaining\",\n  clock = systemClock,\n  ref,\n  className,\n}: CountdownProps) {\n  const root = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => root.current as HTMLDivElement);\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  const reduced = !!useReducedMotion() && hydrated;\n  const end = toEpoch(target, timeZone);\n  const valid = Number.isFinite(end);\n  const shownUnits = units.length ? [...units].sort((a, b) => SECONDS[b] - SECONDS[a]) : DEFAULT_UNITS;\n\n  // The clock only ticks while the countdown is on screen and the tab is visible; coming back reads the clock again, so nothing drifts.\n  const pageVisible = useSyncExternalStore(\n    subscribeVisibility,\n    () => document.visibilityState === \"visible\",\n    () => true,\n  );\n  const inView = useInView(root, { amount: 0 });\n  const running = pageVisible && inView && valid;\n  const [now, setNow] = useState<number | null>(null);\n  const clockRef = useRef(clock);\n  useLayoutEffect(() => {\n    clockRef.current = clock;\n  });\n  useEffect(() => {\n    if (!running) return;\n    let timer: ReturnType<typeof setTimeout>;\n    const tick = () => {\n      const time = clockRef.current();\n      setNow(time);\n      const left = end - time;\n      // Wake just after the next whole second, aligned to the target rather than to when the page loaded.\n      if (left > 0) timer = setTimeout(tick, (left % 1000 || 1000) + 8);\n    };\n    timer = setTimeout(tick, 0);\n    return () => clearTimeout(timer);\n  }, [end, running]);\n\n  // Completion is armed separately, so it lands on time even while ticking is paused.\n  useEffect(() => {\n    if (!valid) return;\n    const left = end - clockRef.current();\n    if (left <= 0) return;\n    const timer = setTimeout(() => setNow(clockRef.current()), Math.min(MAX_TIMEOUT, left + 8));\n    return () => clearTimeout(timer);\n  }, [end, valid]);\n\n  const left = now === null || !valid ? null : Math.max(0, end - now);\n  const complete = left === 0;\n  const onCompleteRef = useRef(onComplete);\n  useLayoutEffect(() => {\n    onCompleteRef.current = onComplete;\n  });\n  const wasRunning = useRef<number | null>(null);\n  useEffect(() => {\n    if (left === null) return;\n    if (left > 0) {\n      wasRunning.current = end;\n      return;\n    }\n    if (wasRunning.current === end) {\n      wasRunning.current = null;\n      onCompleteRef.current?.();\n    }\n  }, [end, left]);\n\n  const seconds = left === null ? 0 : Math.ceil(left / 1000);\n  const parts = partsFor(seconds, shownUnits);\n  const pending = left === null;\n  const compact = variant === \"compact\";\n\n  // The shell springs to the size of whatever it holds, so the digits can morph into the finished label without a jump.\n  const content = useRef<HTMLDivElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\"),\n    height = useMotionValue<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let first = true;\n    const observer = new ResizeObserver(() => {\n      const w = node.offsetWidth,\n        h = node.offsetHeight;\n      if (first || reduced) {\n        width.jump(w);\n        height.jump(h);\n        first = false;\n        return;\n      }\n      animate(width, w, spring.morph);\n      animate(height, h, spring.smooth);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [height, reduced, width]);\n\n  const spoken = complete\n    ? typeof completeLabel === \"string\"\n      ? completeLabel\n      : \"Complete\"\n    : pending\n      ? \"\"\n      : parts\n          .filter((part, index) => part.value > 0 || index === parts.length - 1)\n          .filter((part) => seconds < 60 || part.unit !== \"seconds\")\n          .map((part) => `${part.value} ${part.value === 1 ? NAMES[part.unit][2] : `${NAMES[part.unit][2]}s`}`)\n          .join(\", \");\n  const swap = reduced\n    ? {\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        transition: { duration: motionTokens.duration.fast },\n      }\n    : {\n        initial: { opacity: 0, scale: 0.92, filter: `blur(${motionTokens.blur.soft}px)` },\n        animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n        exit: {\n          opacity: 0,\n          scale: 0.92,\n          filter: `blur(${motionTokens.blur.soft}px)`,\n          transition: { duration: motionTokens.duration.fast },\n        },\n        transition: spring.morph,\n      };\n\n  return (\n    <div\n      ref={root}\n      className={cn(\n        \"relative min-w-0 font-body tracking-body text-ink\",\n        compact ? \"inline-flex align-middle\" : \"@container flex justify-center\",\n        className,\n      )}\n      data-variant={variant}\n      data-complete={complete || undefined}\n      data-pending={pending || undefined}\n      role=\"timer\"\n      aria-label={label}\n    >\n      <motion.div\n        className={cn(\n          \"relative flex justify-center\",\n          compact &&\n            \"overflow-hidden rounded-full border border-line bg-surface transition-[border-color,background-color] duration-standard ease-standard motion-reduce:transition-none\",\n          compact &&\n            complete &&\n            \"border-[color-mix(in_oklab,var(--green)_35%,var(--line))] bg-[color-mix(in_oklab,var(--green)_9%,var(--surface))]\",\n        )}\n        style={{ width, height }}\n      >\n        <div\n          ref={content}\n          className={cn(\"relative inline-flex flex-none items-center justify-center\", compact && \"min-h-8 px-3\")}\n          aria-hidden=\"true\"\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {complete ? (\n              <motion.span\n                key=\"done\"\n                className={cn(\n                  \"inline-flex items-center gap-[.4em] whitespace-nowrap\",\n                  compact\n                    ? \"text-(length:--text-sm) leading-[1.3] font-medium text-green\"\n                    : \"font-display text-[length:clamp(1.75rem,11cqi,var(--text-4xl))] leading-[1.1] font-medium tracking-display\",\n                )}\n                {...swap}\n              >\n                <span className=\"relative size-[.3em] min-h-2 min-w-2 flex-none rounded-[50%] bg-green after:absolute after:inset-0 after:animate-countdown-pulse after:rounded-[inherit] after:bg-green after:content-[''] motion-reduce:after:animate-none\" />\n                {completeLabel}\n              </motion.span>\n            ) : (\n              <motion.span\n                key=\"running\"\n                className={cn(\n                  \"inline-flex items-start whitespace-nowrap\",\n                  compact\n                    ? \"gap-1.5 text-(length:--text-sm) leading-[1.3] font-medium\"\n                    : \"gap-0 font-display text-[length:clamp(1.75rem,12.5cqi,var(--text-5xl))] leading-none font-medium tracking-display\",\n                )}\n                {...swap}\n              >\n                {parts.map((part, group) => {\n                  const text = String(part.value).padStart(2, \"0\");\n                  const bases = basesFor(part.unit, group === 0, text.length);\n                  return (\n                    <span key={part.unit} className=\"inline-flex items-start\">\n                      {!compact && group > 0 && (\n                        <span className=\"-translate-y-[.06em] px-[.12em] leading-none text-ink-3 opacity-60\">:</span>\n                      )}\n                      <span\n                        className={\n                          compact\n                            ? \"inline-flex items-baseline gap-px\"\n                            : \"inline-grid min-w-[min(calc((100cqi_-_1.6em)_/_4),calc(var(--text-xs)_*_4.2))] justify-items-center gap-1.5\"\n                        }\n                      >\n                        <span\n                          className={cn(\n                            \"inline-flex tabular-nums transition-opacity duration-considered ease-standard motion-reduce:transition-none\",\n                            pending && \"opacity-30\",\n                          )}\n                        >\n                          <AnimatePresence initial={false}>\n                            {text.split(\"\").map((char, index) => (\n                              <Wheel\n                                key={`${part.unit}${text.length - index}`}\n                                digit={Number(char)}\n                                base={bases[index]!}\n                                delay={Math.min((group * 2 + index) * stagger.item, 0.3)}\n                                reduced={reduced}\n                              />\n                            ))}\n                          </AnimatePresence>\n                        </span>\n                        <span\n                          className={cn(\n                            \"text-ink-3\",\n                            compact\n                              ? \"text-(length:--text-sm) leading-[1.3] font-normal\"\n                              : \"font-body text-(length:--text-xs) leading-none tracking-body\",\n                          )}\n                        >\n                          {NAMES[part.unit][compact ? 1 : 0]}\n                        </span>\n                      </span>\n                    </span>\n                  );\n                })}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n      </motion.div>\n      <span className=\"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\">{spoken}</span>\n    </div>\n  );\n}\n\nexport default Countdown;\n",
      "type": "registry:component",
      "target": "@components/iris/countdown/countdown.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/countdown",
  "meta": {
    "kind": "component",
    "group": "Feedback",
    "export": "Countdown",
    "tags": [
      "feedback",
      "new"
    ],
    "docs": "https://irisui.dev/components/countdown"
  }
}
