{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "calendar-lume", "type": "registry:ui", "title": "Calendar Lume", "description": "A depth-zoom calendar — drill from years to months to days with scale-based transitions and staggered grids.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/calendar-lume.tsx", "content": "\"use client\";\n\nimport { useState, useMemo, useCallback, useRef } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ── constants ── */\nconst MO = [\n \"Jan\",\n \"Feb\",\n \"Mar\",\n \"Apr\",\n \"May\",\n \"Jun\",\n \"Jul\",\n \"Aug\",\n \"Sep\",\n \"Oct\",\n \"Nov\",\n \"Dec\",\n] as const;\nconst MOFULL = [\n \"January\",\n \"February\",\n \"March\",\n \"April\",\n \"May\",\n \"June\",\n \"July\",\n \"August\",\n \"September\",\n \"October\",\n \"November\",\n \"December\",\n] as const;\nconst WK = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"] as const;\nconst WKFULL = [\n \"Sunday\",\n \"Monday\",\n \"Tuesday\",\n \"Wednesday\",\n \"Thursday\",\n \"Friday\",\n \"Saturday\",\n] as const;\n\n/* ── date math ── */\nfunction daysIn(y: number, m: number) {\n return new Date(y, m + 1, 0).getDate();\n}\nfunction startOff(y: number, m: number) {\n return (new Date(y, m, 1).getDay() + 6) % 7;\n}\nfunction pad(n: number) {\n return String(n).padStart(2, \"0\");\n}\nfunction iso(y: number, m: number, d: number) {\n return `${y}-${pad(m + 1)}-${pad(d)}`;\n}\n\n/* ── sound ── */\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\nfunction _init() {\n if (_ctx) return;\n _ctx = new AudioContext();\n const n = Math.ceil(_ctx.sampleRate * 0.003);\n _buf = _ctx.createBuffer(1, n, _ctx.sampleRate);\n const ch = _buf.getChannelData(0);\n for (let i = 0; i < n; i++) {\n const t = i / n;\n ch[i] = (Math.random() * 2 - 1) * Math.pow(1 - t, 4) * 0.12;\n }\n}\nlet _last = 0;\nfunction _tick() {\n const now = Date.now();\n if (now - _last < 60) return;\n _last = now;\n if (!_ctx || !_buf) return;\n const s = _ctx.createBufferSource();\n s.buffer = _buf;\n s.connect(_ctx.destination);\n s.start();\n}\n\n/* ── types ── */\nexport interface CalendarLumeProps {\n /** ISO date string \"YYYY-MM-DD\" */\n value?: string;\n /** Fires with ISO date string on day selection */\n onChange?: (date: string) => void;\n /** Enable tick sound. Default true */\n sound?: boolean;\n}\n\ntype Level = \"year\" | \"month\" | \"day\";\n\n/* ── stagger helper ── */\nconst stag = (i: number) => ({\n initial: { opacity: 0, y: 5 } as const,\n animate: {\n opacity: 1,\n y: 0,\n transition: { delay: i * 0.016 },\n } as const,\n});\n\n/* ── depth transition variants (functions receiving custom direction) ── */\nconst depthVariants = {\n initial: (dir: string) => ({\n opacity: 0,\n scale: dir === \"in\" ? 0.93 : 1.07,\n }),\n animate: { opacity: 1, scale: 1 },\n exit: (dir: string) => ({\n opacity: 0,\n scale: dir === \"in\" ? 1.07 : 0.93,\n }),\n};\n\nconst SPRING = { type: \"spring\" as const, stiffness: 340, damping: 28 };\n\n/* ── component ── */\nfunction CalendarLume({ value, onChange, sound = true }: CalendarLumeProps) {\n const now = useMemo(() => new Date(), []);\n\n const [year, setYear] = useState(() =>\n value ? Number(value.slice(0, 4)) : now.getFullYear(),\n );\n const [month, setMonth] = useState(() =>\n value ? Number(value.slice(5, 7)) - 1 : now.getMonth(),\n );\n const [day, setDay] = useState(() =>\n value ? Number(value.slice(8, 10)) : now.getDate(),\n );\n const [level, setLevel] = useState(\"day\");\n const [decBase, setDecBase] = useState(\n () =>\n Math.floor((value ? Number(value.slice(0, 4)) : now.getFullYear()) / 12) *\n 12,\n );\n\n /* hover trackers */\n const [hovY, setHovY] = useState(null);\n const [hovM, setHovM] = useState(null);\n const [hovD, setHovD] = useState(null);\n\n /* direction ref — set synchronously before level state change */\n const dirRef = useRef<\"in\" | \"out\">(\"in\");\n\n /* sound */\n const boot = useCallback(() => {\n if (sound) _init();\n }, [sound]);\n const tick = useCallback(() => {\n if (sound) _tick();\n }, [sound]);\n\n /* grids */\n const dayGrid = useMemo(() => {\n const total = daysIn(year, month);\n const off = startOff(year, month);\n const cells: (number | null)[] = [];\n for (let i = 0; i < off; i++) cells.push(null);\n for (let d = 1; d <= total; d++) cells.push(d);\n return cells;\n }, [year, month]);\n\n const yearGrid = useMemo(() => {\n const out: number[] = [];\n for (let i = 0; i < 12; i++) out.push(decBase + i);\n return out;\n }, [decBase]);\n\n const isToday = useCallback(\n (d: number) =>\n d === now.getDate() &&\n month === now.getMonth() &&\n year === now.getFullYear(),\n [now, month, year],\n );\n\n const weekday = useMemo(\n () => WKFULL[new Date(year, month, day).getDay()],\n [year, month, day],\n );\n\n /* ── navigation ── */\n const drillIn = useCallback(\n (lv: Level) => {\n dirRef.current = \"in\";\n boot();\n tick();\n setLevel(lv);\n },\n [boot, tick],\n );\n\n const zoomOut = useCallback(\n (lv: Level) => {\n dirRef.current = \"out\";\n boot();\n tick();\n if (lv === \"year\") setDecBase(Math.floor(year / 12) * 12);\n setLevel(lv);\n },\n [boot, tick, year],\n );\n\n /* month shift at day level */\n const shiftMonth = useCallback(\n (delta: number) => {\n tick();\n let ny = year;\n let nm = month + delta;\n if (nm < 0) {\n nm = 11;\n ny -= 1;\n } else if (nm > 11) {\n nm = 0;\n ny += 1;\n }\n setYear(ny);\n setMonth(nm);\n const max = daysIn(ny, nm);\n if (day > max) setDay(max);\n },\n [tick, year, month, day],\n );\n\n const direction = dirRef.current;\n\n return (\n \n {/* ── header: ‹ [scope label] › ── */}\n \n {/* left arrow */}\n {\n tick();\n if (level === \"day\") shiftMonth(-1);\n else if (level === \"month\") setYear((y) => y - 1);\n else setDecBase((b) => b - 12);\n }}\n whileTap={{ scale: 0.85 }}\n className=\"text-neutral-400 hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-400 transition-colors\"\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n fontFamily: \"inherit\",\n padding: \"4px 10px\",\n }}\n >\n ‹\n \n\n {/* scope label */}\n {level === \"year\" ? (\n \n {decBase} — {decBase + 11}\n \n ) : (\n zoomOut(level === \"day\" ? \"month\" : \"year\")}\n whileTap={{ scale: 0.96 }}\n className=\"text-neutral-600 dark:text-neutral-400 transition-colors\"\n style={{\n background: \"none\",\n border: \"none\",\n fontSize: 12,\n fontWeight: 500,\n fontFamily: \"inherit\",\n cursor: \"pointer\",\n padding: \"4px 8px\",\n borderRadius: 6,\n letterSpacing: \"0.02em\",\n }}\n >\n {level === \"day\" ? `${MOFULL[month]} ${year}` : String(year)}\n \n )}\n\n {/* right arrow */}\n {\n tick();\n if (level === \"day\") shiftMonth(1);\n else if (level === \"month\") setYear((y) => y + 1);\n else setDecBase((b) => b + 12);\n }}\n whileTap={{ scale: 0.85 }}\n className=\"text-neutral-400 hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-400 transition-colors\"\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n fontFamily: \"inherit\",\n padding: \"4px 10px\",\n }}\n >\n ›\n \n \n\n {/* weekday subtitle at day level */}\n {level === \"day\" && (\n \n {weekday}\n \n )}\n {level !== \"day\" &&
}\n\n {/* ── content area ── */}\n \n {/* ─ year picker ─ */}\n {level === \"year\" && (\n \n \n {yearGrid.map((y, i) => {\n const sel = y === year;\n const hov = hovY === y && !sel;\n return (\n {\n setYear(y);\n const max = daysIn(y, month);\n if (day > max) setDay(max);\n drillIn(\"month\");\n }}\n onMouseEnter={() => setHovY(y)}\n onMouseLeave={() => setHovY(null)}\n whileTap={{ scale: 0.92 }}\n className={cn(\n \"border-none transition-colors\",\n sel\n ? \"bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-950 font-semibold\"\n : hov\n ? \"bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300\"\n : \"bg-transparent text-neutral-600 dark:text-neutral-400\",\n )}\n style={{\n fontSize: 13,\n fontFamily: \"inherit\",\n cursor: \"pointer\",\n padding: \"10px 4px\",\n borderRadius: 8,\n textAlign: \"center\" as const,\n }}\n >\n {y}\n \n );\n })}\n
\n \n )}\n\n {/* ─ month picker ─ */}\n {level === \"month\" && (\n \n \n {MO.map((m, i) => {\n const sel = i === month;\n const hov = hovM === i && !sel;\n return (\n {\n setMonth(i);\n const max = daysIn(year, i);\n if (day > max) setDay(max);\n drillIn(\"day\");\n }}\n onMouseEnter={() => setHovM(i)}\n onMouseLeave={() => setHovM(null)}\n whileTap={{ scale: 0.92 }}\n className={cn(\n \"border-none transition-colors\",\n sel\n ? \"bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-950 font-semibold\"\n : hov\n ? \"bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300\"\n : \"bg-transparent text-neutral-600 dark:text-neutral-400\",\n )}\n style={{\n fontSize: 13,\n fontFamily: \"inherit\",\n cursor: \"pointer\",\n padding: \"10px 4px\",\n borderRadius: 8,\n textAlign: \"center\" as const,\n }}\n >\n {m}\n \n );\n })}\n \n \n )}\n\n {/* ─ day picker ─ */}\n {level === \"day\" && (\n \n {/* weekday headers */}\n \n {WK.map((w) => (\n \n {w}\n \n ))}\n \n\n {/* day cells */}\n \n {dayGrid.map((dd, i) => {\n if (dd === null)\n return
;\n\n const sel = dd === day;\n const td = isToday(dd);\n const hov = hovD === dd && !sel;\n\n return (\n {\n setDay(dd);\n tick();\n onChange?.(iso(year, month, dd));\n }}\n onMouseEnter={() => setHovD(dd)}\n onMouseLeave={() => setHovD(null)}\n whileTap={{ scale: 0.88 }}\n className={cn(\n \"relative border-none transition-colors\",\n sel\n ? \"bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-950 font-semibold\"\n : hov\n ? \"bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300\"\n : td\n ? \"bg-transparent text-neutral-900 dark:text-neutral-100 font-semibold\"\n : \"bg-transparent text-neutral-600 dark:text-neutral-400\",\n )}\n style={{\n fontSize: 13,\n fontFamily: \"inherit\",\n cursor: \"pointer\",\n padding: \"10px 4px\",\n borderRadius: 8,\n textAlign: \"center\" as const,\n }}\n >\n {dd}\n {td && !sel && (\n \n )}\n \n );\n })}\n
\n \n )}\n \n \n );\n}\n\nexport { CalendarLume };\n", "type": "registry:ui", "target": "components/ruixen/calendar-lume.tsx" } ] }