{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/countdown/countdown.tsx",
      "type": "registry:component",
      "target": "@components/xzero/countdown/countdown.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode, type Ref } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type MotionValue } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./countdown.module.css\";\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]> = { days: [\"Days\", \"d\", \"day\"], hours: [\"Hours\", \"h\", \"hour\"], minutes: [\"Minutes\", \"m\", \"minute\"], seconds: [\"Seconds\", \"s\", \"second\"] };\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\", { timeZone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(epoch));\n  const get = (type: string) => Number(parts.find(part => part.type === type)?.value ?? 0);\n  return Date.UTC(get(\"year\"), get(\"month\") - 1, get(\"day\"), get(\"hour\"), get(\"minute\"), get(\"second\")) - Math.floor(epoch / 1000) * 1000;\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) => ({ unit, value: index === 0 ? Math.floor(seconds / SECONDS[unit]) : Math.floor(seconds / SECONDS[unit]) % LIMIT[unit] }));\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) => { document.addEventListener(\"visibilitychange\", notify); return () => document.removeEventListener(\"visibilitychange\", notify); };\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 => Math.abs(current) < .02 || Math.abs(current) >= 1 ? \"none\" : `blur(${(Math.abs(current) * motionTokens.blur.subtle).toFixed(2)}px)`);\n  return <motion.span className={styles.glyph} style={{ y, opacity, filter, visibility }}>{digit}</motion.span>;\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) { state.revealed = true; return; }\n    const up = (digit - state.digit + base) % base, 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 <motion.span className={styles.wheel} initial={{ width: 0, opacity: 0 }} animate={{ width: \"auto\", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : spring.morph}>\n    <span className={styles.sizer}>0</span>\n    {Array.from({ length: base }, (_, value) => <Glyph key={value} position={position} digit={value} base={base} />)}\n  </motion.span>;\n}\n\nexport function Countdown({ target, timeZone, units = DEFAULT_UNITS, variant = \"large\", completeLabel = \"Live now\", onComplete, label = \"Time remaining\", clock = systemClock, ref, className }: CountdownProps) {\n  const root = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => root.current as HTMLDivElement);\n  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\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(subscribeVisibility, () => document.visibilityState === \"visible\", () => true);\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(() => { clockRef.current = clock; });\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(() => { onCompleteRef.current = onComplete; });\n  const wasRunning = useRef<number | null>(null);\n  useEffect(() => {\n    if (left === null) return;\n    if (left > 0) { wasRunning.current = end; return; }\n    if (wasRunning.current === end) { wasRunning.current = null; onCompleteRef.current?.(); }\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\"), 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, h = node.offsetHeight;\n      if (first || reduced) { width.jump(w); height.jump(h); first = false; return; }\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 ? (typeof completeLabel === \"string\" ? completeLabel : \"Complete\") : pending ? \"\" : parts.filter((part, index) => part.value > 0 || index === parts.length - 1).filter(part => seconds < 60 || part.unit !== \"seconds\").map(part => `${part.value} ${part.value === 1 ? NAMES[part.unit][2] : `${NAMES[part.unit][2]}s`}`).join(\", \");\n  const swap = reduced\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: motionTokens.duration.fast } }\n    : { initial: { opacity: 0, scale: .92, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" }, exit: { opacity: 0, scale: .92, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast } }, transition: spring.morph };\n\n  return <div ref={root} className={[styles.countdown, className].filter(Boolean).join(\" \")} data-variant={variant} data-complete={complete || undefined} data-pending={pending || undefined} role=\"timer\" aria-label={label}>\n    <motion.div className={styles.shell} style={{ width, height }}>\n      <div ref={content} className={styles.content} aria-hidden=\"true\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {complete\n            ? <motion.span key=\"done\" className={styles.done} {...swap}><span className={styles.pulse} />{completeLabel}</motion.span>\n            : <motion.span key=\"running\" className={styles.units} {...swap}>\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 <span key={part.unit} className={styles.unit}>\n                  {!compact && group > 0 && <span className={styles.colon}>:</span>}\n                  <span className={styles.group}>\n                    <span className={styles.number}><AnimatePresence initial={false}>\n                      {[...text].map((char, index) => <Wheel key={`${part.unit}${text.length - index}`} digit={Number(char)} base={bases[index]!} delay={Math.min((group * 2 + index) * stagger.item, .3)} reduced={reduced} />)}\n                    </AnimatePresence></span>\n                    <span className={styles.name}>{NAMES[part.unit][compact ? 1 : 0]}</span>\n                  </span>\n                </span>;\n              })}\n            </motion.span>}\n        </AnimatePresence>\n      </div>\n    </motion.div>\n    <span className={styles.srOnly}>{spoken}</span>\n  </div>;\n}\n\nexport default Countdown;\n"
    },
    {
      "path": "registry/components/countdown/countdown.module.css",
      "type": "registry:component",
      "target": "@components/xzero/countdown/countdown.module.css",
      "content": ".countdown { position: relative; min-width: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n.countdown[data-variant=\"large\"] { container-type: inline-size; display: flex; justify-content: center; }\n.countdown[data-variant=\"compact\"] { display: inline-flex; vertical-align: middle; }\n\n/* The shell springs to whatever it holds; the compact pill shows that morph as its own outline. */\n.shell { position: relative; display: flex; justify-content: center; }\n.content { position: relative; display: inline-flex; flex: none; align-items: center; justify-content: center; }\n.countdown[data-variant=\"compact\"] .shell { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }\n.countdown[data-variant=\"compact\"] .content { min-height: 32px; padding: 0 var(--space-3); }\n.countdown[data-variant=\"compact\"][data-complete] .shell { border-color: color-mix(in oklab, var(--success) 35%, var(--border)); background: color-mix(in oklab, var(--success) 9%, var(--surface)); }\n\n.units { display: inline-flex; align-items: flex-start; white-space: nowrap; }\n.unit { display: inline-flex; align-items: flex-start; }\n.group { display: inline-grid; justify-items: center; gap: 6px; }\n.number { display: inline-flex; font-variant-numeric: tabular-nums; transition: opacity var(--duration-considered) var(--ease-standard); }\n.countdown[data-pending] .number { opacity: .3; }\n.name { color: var(--text-muted); font-size: var(--text-xs); line-height: 1; }\n.colon { color: var(--text-muted); opacity: .6; }\n\n/* Large: display digits sized to the container, so four groups fit at 320px and breathe on a wide card. */\n.countdown[data-variant=\"large\"] .units { gap: 0; font-family: var(--font-display); font-size: clamp(1.75rem, 12.5cqi, var(--text-5xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1; }\n.countdown[data-variant=\"large\"] .colon { padding-inline: .12em; line-height: 1; transform: translateY(-.06em); }\n/* On a narrow card the unit names outgrow the digits; an even column floor keeps the colons evenly spaced instead of following each name's width. */\n.countdown[data-variant=\"large\"] .group { min-width: min(calc((100cqi - 1.6em) / 4), calc(var(--text-xs) * 4.2)); }\n.countdown[data-variant=\"large\"] .name { font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Compact: one line, digits then a quiet unit letter. */\n.countdown[data-variant=\"compact\"] .units { gap: 6px; font-size: var(--text-sm); font-weight: 500; line-height: 1.3; }\n.countdown[data-variant=\"compact\"] .group { display: inline-flex; align-items: baseline; gap: 1px; }\n.countdown[data-variant=\"compact\"] .name { font-size: var(--text-sm); font-weight: 400; line-height: 1.3; }\n\n/* Each digit is a clipped wheel whose window feathers out, so a turning digit fades at the edge instead of being cut. */\n.wheel { --feather: .16em; position: relative; display: inline-block; overflow: hidden; margin-block: calc(var(--feather) * -1); padding-block: var(--feather); mask-image: linear-gradient(to bottom, transparent, #000 calc(var(--feather) * 1.5), #000 calc(100% - var(--feather) * 1.5), transparent); }\n.sizer { visibility: hidden; }\n.glyph { position: absolute; inset: var(--feather) 0; text-align: center; }\n\n/* Finished: the digits give way to the label and a live mark. */\n.done { display: inline-flex; align-items: center; gap: .4em; white-space: nowrap; }\n.countdown[data-variant=\"large\"] .done { font-family: var(--font-display); font-size: clamp(1.75rem, 11cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1.1; }\n.countdown[data-variant=\"compact\"] .done { color: var(--success); font-size: var(--text-sm); font-weight: 500; line-height: 1.3; }\n.pulse { position: relative; width: .3em; height: .3em; min-width: 8px; min-height: 8px; flex: none; border-radius: 50%; background: var(--success); }\n.pulse::after { position: absolute; inset: 0; border-radius: inherit; background: var(--success); content: \"\"; animation: pulse 2s var(--ease-standard) infinite; }\n@keyframes pulse { from { opacity: .5; transform: scale(1); } 70%, to { opacity: 0; transform: scale(2.6); } }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) {\n  .pulse::after { animation: none; }\n  .number, .shell { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/countdown",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "feedback",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/countdown",
    "markdown": "https://ui.xzero.co.uk/components/countdown/markdown"
  }
}