{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "calendar-wave", "type": "registry:ui", "title": "Calendar Wave", "description": "A calendar where days rise toward your cursor like water — cosine wave ripple, spring physics, shadow depth.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/calendar-wave.tsx", "content": "\"use client\";\n\nimport { useState, useMemo, useRef, useCallback } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Calendar Wave — a calendar where days respond to your cursor\n * like the surface of water.\n *\n * Move over the grid: nearby days rise toward you, creating a\n * smooth cosine wave that ripples outward. Lifted cells brighten\n * and cast deeper shadows. Click to select — the chosen day\n * locks at peak height with a spring pop.\n *\n * The surface IS the interaction.\n */\n\n/* ── constants ── */\nconst MONTHS = [\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];\nconst DOW = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\nconst CELL = 40;\nconst GAP = 2;\nconst STEP = CELL + GAP;\nconst RADIUS = 110;\nconst MAX_LIFT = 8;\n\n/* ── date math ── */\nfunction dim(y: number, m: number) {\n return new Date(y, m + 1, 0).getDate();\n}\nfunction soff(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 toKey(y: number, m: number, d: number) {\n return `${y}-${pad(m + 1)}-${pad(d)}`;\n}\nfunction parseKey(k: string) {\n const [y, m, d] = k.split(\"-\").map(Number);\n return { y, m: m - 1, d };\n}\n\n/* ── wave math ── */\nfunction waveLift(mx: number, my: number, col: number, row: number): number {\n const cx = col * STEP + CELL / 2;\n const cy = row * STEP + CELL / 2;\n const dx = mx - cx;\n const dy = my - cy;\n const dist = Math.sqrt(dx * dx + dy * dy);\n if (dist >= RADIUS) return 0;\n const t = dist / RADIUS;\n return (MAX_LIFT * (1 + Math.cos(Math.PI * t))) / 2;\n}\n\n/* ── sound ── */\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\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}\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 CalendarWaveProps {\n /** Selected date as \"YYYY-MM-DD\" */\n value?: string;\n /** Fires with \"YYYY-MM-DD\" on day selection */\n onChange?: (date: string) => void;\n /** Enable tick sound. Default true */\n sound?: boolean;\n}\n\n/* ── component ── */\nexport function CalendarWave({\n value,\n onChange,\n sound = true,\n}: CalendarWaveProps) {\n const now = useMemo(() => new Date(), []);\n\n const [month, setMonth] = useState(() =>\n value ? Number(value.slice(5, 7)) - 1 : now.getMonth(),\n );\n const [year, setYear] = useState(() =>\n value ? Number(value.slice(0, 4)) : now.getFullYear(),\n );\n const [selected, setSelected] = useState(value ?? null);\n const [mousePos, setMousePos] = useState({ x: -9999, y: -9999 });\n const [hovering, setHovering] = useState(false);\n const [dir, setDir] = useState(0);\n const gridRef = useRef(null);\n\n const today = toKey(now.getFullYear(), now.getMonth(), now.getDate());\n\n const tick = useCallback(() => {\n if (!sound) return;\n _init();\n _tick();\n }, [sound]);\n\n /* grid data */\n const days = dim(year, month);\n const offset = soff(year, month);\n\n const cells = useMemo(() => {\n const result: { day: number | null; col: number; row: number }[] = [];\n let idx = 0;\n for (let i = 0; i < offset; i++) {\n result.push({ day: null, col: idx % 7, row: Math.floor(idx / 7) });\n idx++;\n }\n for (let d = 1; d <= days; d++) {\n result.push({ day: d, col: idx % 7, row: Math.floor(idx / 7) });\n idx++;\n }\n return result;\n }, [year, month, days, offset]);\n\n /* mouse tracking */\n const handleMouseMove = useCallback((e: React.MouseEvent) => {\n const rect = gridRef.current?.getBoundingClientRect();\n if (!rect) return;\n setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });\n }, []);\n\n /* navigation */\n const nav = (delta: number) => {\n tick();\n setDir(delta);\n let m = month + delta;\n let y = year;\n if (m < 0) {\n m = 11;\n y--;\n }\n if (m > 11) {\n m = 0;\n y++;\n }\n setMonth(m);\n setYear(y);\n };\n\n /* selection */\n const pick = (d: number) => {\n tick();\n const key = toKey(year, month, d);\n setSelected(key);\n onChange?.(key);\n };\n\n /* selected date display */\n const selDisplay = selected\n ? (() => {\n const { y, m, d } = parseKey(selected);\n const date = new Date(y, m, d);\n return date.toLocaleDateString(\"en-US\", {\n weekday: \"long\",\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n })()\n : null;\n\n return (\n \n {/* ── header ── */}\n \n nav(-1)}\n whileTap={{ scale: 0.85 }}\n className=\"text-neutral-400 transition-colors hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-400\"\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n padding: \"4px 10px\",\n }}\n >\n ‹\n \n\n \n 0 ? 8 : -8, opacity: 0 }}\n animate={{ y: 0, opacity: 1 }}\n exit={{ y: dir > 0 ? -8 : 8, opacity: 0 }}\n transition={{ type: \"spring\", damping: 24, stiffness: 300 }}\n className=\"text-neutral-900 dark:text-neutral-100\"\n style={{\n fontSize: 15,\n fontWeight: 600,\n letterSpacing: \"-0.02em\",\n }}\n >\n {MONTHS[month]} {year}\n \n \n\n nav(1)}\n whileTap={{ scale: 0.85 }}\n className=\"text-neutral-400 transition-colors hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-400\"\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n padding: \"4px 10px\",\n }}\n >\n ›\n \n \n\n {/* ── DOW headers ── */}\n \n {DOW.map((d) => (\n \n {d}\n \n ))}\n \n\n {/* ── grid with wave ── */}\n \n 0 ? 16 : -16 }}\n animate={{ opacity: 1, x: 0 }}\n exit={{ opacity: 0, x: dir > 0 ? -16 : 16 }}\n transition={{ type: \"spring\", damping: 26, stiffness: 300 }}\n >\n setHovering(true)}\n onMouseLeave={() => {\n setHovering(false);\n setMousePos({ x: -9999, y: -9999 });\n }}\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(7, ${CELL}px)`,\n gap: GAP,\n }}\n >\n {cells.map((cell, i) => {\n if (cell.day === null) {\n return (\n
\n );\n }\n\n const key = toKey(year, month, cell.day);\n const isSel = key === selected;\n const isToday = key === today;\n\n const lift = hovering\n ? waveLift(mousePos.x, mousePos.y, cell.col, cell.row)\n : 0;\n\n const effectiveLift = isSel ? MAX_LIFT : lift;\n\n const shadow =\n effectiveLift > 0.5\n ? `0 ${Math.round(effectiveLift * 1.5)}px ${Math.round(effectiveLift * 3)}px rgba(0,0,0,${(0.03 + effectiveLift * 0.008).toFixed(3)})`\n : \"none\";\n\n return (\n pick(cell.day!)}\n animate={{\n y: -effectiveLift,\n scale: 1 + effectiveLift * 0.005,\n }}\n whileTap={{ scale: 0.92 }}\n transition={{\n type: \"spring\",\n damping: 20,\n stiffness: 300,\n mass: 0.4,\n }}\n className={cn(\n \"relative flex items-center justify-center rounded-[10px] transition-colors duration-150\",\n isSel\n ? \"bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-950\"\n : isToday\n ? \"bg-transparent text-neutral-900 dark:text-neutral-100\"\n : lift > 3\n ? \"bg-neutral-50 text-neutral-700 dark:bg-neutral-900 dark:text-neutral-300\"\n : lift > 1\n ? \"bg-transparent text-neutral-600 dark:text-neutral-400\"\n : \"bg-transparent text-neutral-400 dark:text-neutral-600\",\n )}\n style={{\n width: CELL,\n height: CELL,\n border: \"none\",\n cursor: \"pointer\",\n padding: 0,\n fontWeight: isSel || isToday ? 600 : lift > 2 ? 500 : 400,\n fontSize: 13,\n fontVariantNumeric: \"tabular-nums\",\n lineHeight: 1,\n boxShadow: shadow,\n }}\n >\n {cell.day}\n {isToday && !isSel && (\n \n )}\n \n );\n })}\n
\n \n
\n\n {/* ── selected date label ── */}\n \n {selDisplay && (\n \n {selDisplay}\n \n )}\n \n \n );\n}\n\nexport default CalendarWave;\n", "type": "registry:ui", "target": "components/ruixen/calendar-wave.tsx" } ] }