{
  "$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<string | null>(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<HTMLDivElement>(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    <div\n      className=\"select-none\"\n      style={{\n        width: 7 * STEP - GAP + 48,\n        padding: \"28px 24px\",\n        fontFamily:\n          \"'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif\",\n      }}\n    >\n      {/* ── header ── */}\n      <div\n        style={{\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"space-between\",\n          marginBottom: 20,\n        }}\n      >\n        <motion.button\n          onClick={() => 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        </motion.button>\n\n        <AnimatePresence mode=\"wait\" initial={false}>\n          <motion.span\n            key={`${year}-${month}`}\n            initial={{ y: dir > 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          </motion.span>\n        </AnimatePresence>\n\n        <motion.button\n          onClick={() => 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        </motion.button>\n      </div>\n\n      {/* ── DOW headers ── */}\n      <div\n        style={{\n          display: \"grid\",\n          gridTemplateColumns: `repeat(7, ${CELL}px)`,\n          gap: GAP,\n          marginBottom: 6,\n        }}\n      >\n        {DOW.map((d) => (\n          <div\n            key={d}\n            className=\"text-neutral-300 dark:text-neutral-700\"\n            style={{\n              fontSize: 10,\n              fontWeight: 500,\n              textAlign: \"center\",\n              textTransform: \"uppercase\",\n              letterSpacing: \"0.06em\",\n            }}\n          >\n            {d}\n          </div>\n        ))}\n      </div>\n\n      {/* ── grid with wave ── */}\n      <AnimatePresence mode=\"wait\" initial={false}>\n        <motion.div\n          key={`${year}-${month}`}\n          initial={{ opacity: 0, x: dir > 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          <div\n            ref={gridRef}\n            onMouseMove={handleMouseMove}\n            onMouseEnter={() => 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                  <div key={`e-${i}`} style={{ width: CELL, height: CELL }} />\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                <motion.button\n                  key={cell.day}\n                  onClick={() => 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                    <span className=\"absolute bottom-1 left-1/2 h-[3px] w-[3px] -translate-x-1/2 rounded-full bg-neutral-400 dark:bg-neutral-600\" />\n                  )}\n                </motion.button>\n              );\n            })}\n          </div>\n        </motion.div>\n      </AnimatePresence>\n\n      {/* ── selected date label ── */}\n      <AnimatePresence>\n        {selDisplay && (\n          <motion.div\n            initial={{ opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: 4 }}\n            transition={{ type: \"spring\", damping: 24, stiffness: 300 }}\n            className=\"text-neutral-500 dark:text-neutral-500\"\n            style={{\n              marginTop: 16,\n              textAlign: \"center\",\n              fontSize: 12,\n              fontWeight: 450,\n              letterSpacing: \"-0.005em\",\n            }}\n          >\n            {selDisplay}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport default CalendarWave;\n",
      "type": "registry:ui",
      "target": "components/ruixen/calendar-wave.tsx"
    }
  ]
}