{ "$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(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 \n {prev !== null && (\n \n {prev}\n \n )}\n \n {cur}\n \n \n );\n}\n\n/* ── digit pair container ── */\nfunction Pair({ value }: { value: number }) {\n return (\n
\n \n \n
\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 \n
\n \n {/* title */}\n \n {title}\n \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
\n {time.days > 0 && (\n <>\n \n days\n :\n \n \n )}\n \n hrs\n :\n \n \n min\n :\n \n \n sec\n
\n\n {/* action */}\n {action && (\n \n {action.label}\n \n \n )}\n\n {/* dismiss */}\n \n \n \n
\n \n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/banner-countdown.tsx" } ] }