{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "notifications-carousel", "type": "registry:ui", "title": "Notifications Carousel", "description": "Vertical drum carousel with 3D cylinder rotation, proximity brightness, and spring snap.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/notifications-carousel.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\nimport { motion, AnimatePresence, animate } from \"motion/react\";\n\n/**\n * Notifications Carousel — Rauno Freiberg craft.\n *\n * Vertical spring carousel. Center item is full brightness + draggable.\n * Adjacent items fade with proximity falloff. Vertical drag/scroll to navigate.\n * Horizontal swipe on focused item to dismiss. Tap to select.\n * Spring physics everywhere. Micro noise-burst audio on detent.\n */\n\n/* ── Audio singleton ── */\n\nlet _a: AudioContext | null = null;\nlet _b: AudioBuffer | null = null;\n\nfunction getCtx(): AudioContext {\n if (!_a)\n _a = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n if (_a.state === \"suspended\") _a.resume();\n return _a;\n}\n\nfunction getBuf(ac: AudioContext): AudioBuffer {\n if (_b && _b.sampleRate === ac.sampleRate) return _b;\n const len = Math.floor(ac.sampleRate * 0.003);\n const buf = ac.createBuffer(1, len, ac.sampleRate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n ch[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 4;\n _b = buf;\n return buf;\n}\n\nfunction tick(ref: React.MutableRefObject) {\n const now = performance.now();\n if (now - ref.current < 30) return;\n ref.current = now;\n try {\n const ac = getCtx();\n const src = ac.createBufferSource();\n const g = ac.createGain();\n src.buffer = getBuf(ac);\n g.gain.value = 0.07;\n src.connect(g).connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Types ── */\n\ninterface CarouselItem {\n id: string;\n title: string;\n body: string;\n time: string;\n}\n\ninterface NotificationsCarouselProps {\n items?: CarouselItem[];\n onDismiss?: (id: string) => void;\n onSelect?: (id: string) => void;\n sound?: boolean;\n}\n\n/* ── Defaults ── */\n\nconst DEFAULTS: CarouselItem[] = [\n {\n id: \"1\",\n title: \"Deployment complete\",\n body: \"v2.4.1 deployed to production successfully\",\n time: \"2m ago\",\n },\n {\n id: \"2\",\n title: \"Review requested\",\n body: \"Alex requested your review on PR #482\",\n time: \"8m ago\",\n },\n {\n id: \"3\",\n title: \"Build passed\",\n body: \"Pipeline #846 completed in 3m 42s\",\n time: \"24m ago\",\n },\n {\n id: \"4\",\n title: \"New comment\",\n body: \"Sarah commented on your pull request\",\n time: \"1h ago\",\n },\n {\n id: \"5\",\n title: \"Security alert\",\n body: \"New login detected from San Francisco\",\n time: \"2h ago\",\n },\n {\n id: \"6\",\n title: \"Invoice paid\",\n body: \"Payment of $3,200 received from Acme\",\n time: \"4h ago\",\n },\n {\n id: \"7\",\n title: \"Team invitation\",\n body: \"You were invited to join Project Alpha\",\n time: \"6h ago\",\n },\n {\n id: \"8\",\n title: \"Weekly report\",\n body: \"Your weekly analytics summary is ready\",\n time: \"1d ago\",\n },\n];\n\n/* ── CSS ── */\n\nconst CSS = `.nc{--nc-bg:rgba(255,255,255,.72);--nc-border:rgba(0,0,0,.06);--nc-shadow:0 0 0 .5px rgba(0,0,0,.04),0 2px 4px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.06);--nc-ink:0,0,0;--nc-card:rgba(255,255,255,.55);--nc-card-hi:rgba(255,255,255,.85)}.dark .nc,[data-theme=\"dark\"] .nc{--nc-bg:rgba(30,30,32,.82);--nc-border:rgba(255,255,255,.06);--nc-shadow:0 0 0 .5px rgba(255,255,255,.04),0 2px 4px rgba(0,0,0,.2),0 8px 24px rgba(0,0,0,.3);--nc-ink:255,255,255;--nc-card:rgba(255,255,255,.04);--nc-card-hi:rgba(255,255,255,.08)}.nc-row{cursor:grab;touch-action:none}.nc-row:active{cursor:grabbing}`;\n\n/* ── Constants ── */\n\nconst ROW_H = 68;\nconst GAP = 6;\nconst STEP = ROW_H + GAP;\nconst SPRING = { type: \"spring\" as const, stiffness: 400, damping: 32 };\nconst DISMISS_THRESHOLD = 80;\n\nfunction clamp(v: number, lo: number, hi: number) {\n return Math.max(lo, Math.min(hi, v));\n}\n\n/* ── Component ── */\n\nexport function NotificationsCarousel({\n items: ext,\n onDismiss,\n onSelect,\n sound = true,\n}: NotificationsCarouselProps) {\n const [internal, setInternal] = useState(\n () => ext ?? DEFAULTS,\n );\n const items = ext ?? internal;\n const [idx, setIdx] = useState(0);\n const prevIdx = useRef(0);\n const lastSound = useRef(0);\n const scrollAccum = useRef(0);\n const containerRef = useRef(null);\n const dragStartY = useRef(0);\n const dragStartIdx = useRef(0);\n\n // Keep idx in bounds\n const safeIdx = clamp(idx, 0, items.length - 1);\n\n const go = useCallback(\n (next: number) => {\n const c = clamp(next, 0, items.length - 1);\n if (c !== prevIdx.current && sound) tick(lastSound);\n prevIdx.current = c;\n setIdx(c);\n },\n [items.length, sound],\n );\n\n const dismiss = useCallback(\n (id: string) => {\n if (sound) tick(lastSound);\n if (ext) {\n onDismiss?.(id);\n } else {\n setInternal((p) => {\n const next = p.filter((n) => n.id !== id);\n // Adjust index if needed\n const newIdx = Math.min(prevIdx.current, next.length - 1);\n prevIdx.current = Math.max(0, newIdx);\n setIdx(Math.max(0, newIdx));\n return next;\n });\n onDismiss?.(id);\n }\n },\n [ext, onDismiss, sound],\n );\n\n // Wheel navigation\n useEffect(() => {\n const el = containerRef.current;\n if (!el) return;\n const handler = (e: WheelEvent) => {\n e.preventDefault();\n scrollAccum.current += e.deltaY;\n if (Math.abs(scrollAccum.current) >= 45) {\n go(prevIdx.current + Math.sign(scrollAccum.current));\n scrollAccum.current = 0;\n }\n };\n el.addEventListener(\"wheel\", handler, { passive: false });\n return () => el.removeEventListener(\"wheel\", handler);\n }, [go]);\n\n // Keyboard\n useEffect(() => {\n const handler = (e: KeyboardEvent) => {\n if (e.key === \"ArrowDown\" || e.key === \"ArrowRight\") {\n e.preventDefault();\n go(safeIdx + 1);\n }\n if (e.key === \"ArrowUp\" || e.key === \"ArrowLeft\") {\n e.preventDefault();\n go(safeIdx - 1);\n }\n };\n const el = containerRef.current;\n el?.addEventListener(\"keydown\", handler);\n return () => el?.removeEventListener(\"keydown\", handler);\n }, [go, safeIdx]);\n\n const viewH = STEP * 5;\n\n if (items.length === 0) {\n return (\n \n