{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "banner-countdown",
  "type": "registry:ui",
  "title": "Banner Countdown",
  "description": "A countdown banner with Fluid Numerals — odometer-style rolling digits, colon heartbeat, and height-collapse dismiss.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ruixenui/banner-countdown.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ArrowRight, X } from \"lucide-react\";\n\n/* ═══════════════════════════════════════════════════════════\n   Fluid Numerals — digits that roll like a physical odometer.\n   Each digit is independent: when 10→09 the tens-place rolls\n   1→0 separately from the ones-place rolling 0→9.\n   ═══════════════════════════════════════════════════════════ */\n\nexport interface BannerCountdownProps {\n  title: string;\n  endDate: Date;\n  action?: { label: string; href: string };\n  onExpire?: () => void;\n  onDismiss?: () => void;\n  className?: string;\n}\n\n/* ── time math ── */\nfunction calc(end: Date) {\n  const s = Math.max(0, Math.floor((end.getTime() - Date.now()) / 1000));\n  return {\n    total: s,\n    days: Math.floor(s / 86400),\n    hours: Math.floor((s % 86400) / 3600),\n    minutes: Math.floor((s % 3600) / 60),\n    seconds: s % 60,\n  };\n}\n\n/* ── single rolling digit (0-9) ── */\nfunction Digit({ value }: { value: number }) {\n  const [cur, setCur] = React.useState(value);\n  const [prev, setPrev] = React.useState<number | null>(null);\n\n  React.useEffect(() => {\n    if (value !== cur) {\n      setPrev(cur);\n      setCur(value);\n      const id = setTimeout(() => setPrev(null), 400);\n      return () => clearTimeout(id);\n    }\n  }, [value, cur]);\n\n  return (\n    <span className=\"relative inline-flex h-[1.4em] w-[0.62em] items-center justify-center overflow-hidden\">\n      {prev !== null && (\n        <span className=\"absolute inset-0 flex items-center justify-center animate-[digit-out_0.35s_ease-in_forwards]\">\n          {prev}\n        </span>\n      )}\n      <span\n        key={cur}\n        className={cn(\n          \"absolute inset-0 flex items-center justify-center\",\n          prev !== null &&\n            \"animate-[digit-in_0.35s_cubic-bezier(0.16,1,0.3,1)]\",\n        )}\n      >\n        {cur}\n      </span>\n    </span>\n  );\n}\n\n/* ── digit pair container ── */\nfunction Pair({ value }: { value: number }) {\n  return (\n    <div className=\"flex items-center justify-center rounded-md bg-foreground/[0.04] px-2.5 py-1.5 font-mono text-xl font-medium tabular-nums leading-none text-foreground\">\n      <Digit value={Math.floor(value / 10)} />\n      <Digit value={value % 10} />\n    </div>\n  );\n}\n\n/* ── main banner ── */\nexport function BannerCountdown({\n  title,\n  endDate,\n  action,\n  onExpire,\n  onDismiss,\n  className,\n}: BannerCountdownProps) {\n  const [mounted, setMounted] = React.useState(false);\n  const [dismissed, setDismissed] = React.useState(false);\n  const [time, setTime] = React.useState(() => calc(endDate));\n  const expiredRef = React.useRef(false);\n  const onExpireRef = React.useRef(onExpire);\n  onExpireRef.current = onExpire;\n\n  /* entrance expand */\n  React.useEffect(() => {\n    requestAnimationFrame(() => {\n      requestAnimationFrame(() => setMounted(true));\n    });\n  }, []);\n\n  /* tick */\n  React.useEffect(() => {\n    const id = setInterval(() => {\n      const t = calc(endDate);\n      setTime(t);\n      if (t.total <= 0 && !expiredRef.current) {\n        expiredRef.current = true;\n        onExpireRef.current?.();\n        clearInterval(id);\n      }\n    }, 1000);\n    return () => clearInterval(id);\n  }, [endDate]);\n\n  const handleDismiss = () => {\n    setDismissed(true);\n    if (onDismiss) setTimeout(onDismiss, 350);\n  };\n\n  const tick = time.seconds % 2 === 0;\n\n  const colonCn = cn(\n    \"self-center text-sm font-extralight text-muted-foreground transition-opacity duration-500\",\n    tick ? \"opacity-30\" : \"opacity-10\",\n  );\n\n  const labelCn =\n    \"text-center text-[9px] font-medium uppercase tracking-[0.15em] text-muted-foreground/30\";\n\n  return (\n    <div\n      className={cn(\n        \"grid transition-[grid-template-rows] ease-[cubic-bezier(0.16,1,0.3,1)]\",\n        mounted && !dismissed\n          ? \"[grid-template-rows:1fr] duration-500\"\n          : \"[grid-template-rows:0fr] duration-300\",\n      )}\n    >\n      <div className=\"overflow-hidden\">\n        <div\n          className={cn(\n            \"relative flex flex-wrap items-center justify-center gap-x-8 gap-y-3 px-12 py-4\",\n            \"after:pointer-events-none after:absolute after:bottom-0 after:inset-x-0 after:h-px after:bg-gradient-to-r after:from-transparent after:via-border after:to-transparent\",\n            \"transition-[opacity,transform] duration-150\",\n            dismissed && \"opacity-0 -translate-y-1\",\n            className,\n          )}\n        >\n          {/* title */}\n          <span className=\"text-sm font-medium text-foreground/70\">\n            {title}\n          </span>\n\n          {/* Fluid Numerals — CSS grid keeps colons aligned with pairs,\n              labels in their own row so they don't shift vertical center */}\n          <div className=\"inline-grid grid-flow-col grid-rows-[auto_auto] items-center gap-x-2 gap-y-1\">\n            {time.days > 0 && (\n              <>\n                <Pair value={time.days} />\n                <span className={labelCn}>days</span>\n                <span className={colonCn}>:</span>\n                <span />\n              </>\n            )}\n            <Pair value={time.hours} />\n            <span className={labelCn}>hrs</span>\n            <span className={colonCn}>:</span>\n            <span />\n            <Pair value={time.minutes} />\n            <span className={labelCn}>min</span>\n            <span className={colonCn}>:</span>\n            <span />\n            <Pair value={time.seconds} />\n            <span className={labelCn}>sec</span>\n          </div>\n\n          {/* action */}\n          {action && (\n            <a\n              href={action.href}\n              className=\"group inline-flex shrink-0 items-center gap-0.5 text-sm font-medium text-foreground/60 transition-colors hover:text-foreground\"\n            >\n              {action.label}\n              <ArrowRight className=\"size-3 transition-transform duration-200 group-hover:translate-x-0.5\" />\n            </a>\n          )}\n\n          {/* dismiss */}\n          <button\n            type=\"button\"\n            onClick={handleDismiss}\n            className=\"absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-foreground/20 transition-all duration-150 hover:bg-foreground/5 hover:text-foreground/50 active:scale-90\"\n            aria-label=\"Dismiss\"\n          >\n            <X className=\"size-3.5\" />\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ruixen/banner-countdown.tsx"
    }
  ]
}