{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "notification", "type": "registry:ui", "title": "Notification", "description": "Spring toast stack with drag-to-dismiss, rubber-band physics, and glass morphism.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/notification.tsx", "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/**\n * Spring Toast Stack — Rauno Freiberg craft.\n *\n * Notification cards stack with depth compression —\n * diminishing scale, progressive blur, fading opacity.\n * Top card is draggable: swipe right to dismiss, rubber-band left.\n * Each card owns its drag state via motion `drag` prop\n * (no shared MotionValue, no interference between exit + enter).\n * Stack reshuffles with spring physics on every dismissal.\n * Micro noise-burst audio tick. CSS :active cursor.\n */\n\n/* ── Audio — 3ms noise burst singleton ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction getAudio(): AudioContext {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction ensureBuf(ac: AudioContext): AudioBuffer {\n if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\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 _buf = buf;\n return buf;\n}\n\nfunction tick() {\n try {\n const ac = getAudio();\n const src = ac.createBufferSource();\n const g = ac.createGain();\n src.buffer = ensureBuf(ac);\n g.gain.value = 0.06;\n src.connect(g).connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Types ── */\n\ninterface NotificationItem {\n id: string;\n title: string;\n body: string;\n time: string;\n type?: \"default\" | \"success\" | \"error\";\n}\n\ninterface NotificationProps {\n items?: NotificationItem[];\n onDismiss?: (id: string) => void;\n sound?: boolean;\n}\n\n/* ── Default data ── */\n\nconst DEFAULT_ITEMS: NotificationItem[] = [\n {\n id: \"1\",\n title: \"Deployment complete\",\n body: \"Your app has been deployed to production.\",\n time: \"2m ago\",\n type: \"success\",\n },\n {\n id: \"2\",\n title: \"Payment received\",\n body: \"You received $2,400 from Acme Corp.\",\n time: \"5m ago\",\n },\n {\n id: \"3\",\n title: \"Build failed\",\n body: \"Pipeline #847 failed at test stage.\",\n time: \"12m ago\",\n type: \"error\",\n },\n {\n id: \"4\",\n title: \"New comment\",\n body: \"Sarah left a comment on your pull request.\",\n time: \"1h ago\",\n },\n];\n\n/* ── Scoped CSS ── */\n\nconst STYLE = `.nt{--nt-glass:rgba(255,255,255,.72);--nt-border:rgba(0,0,0,.06);--nt-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);--nt-ink:0,0,0;--nt-ok:#34C759;--nt-err:#FF3B30}.dark .nt,[data-theme=\"dark\"] .nt{--nt-glass:rgba(30,30,32,.82);--nt-border:rgba(255,255,255,.06);--nt-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);--nt-ink:255,255,255;--nt-ok:#30D158;--nt-err:#FF453A}.nt-grab{cursor:grab}.nt-grab:active{cursor:grabbing}`;\n\n/* ── Springs ── */\n\nconst SPRING = { type: \"spring\" as const, stiffness: 380, damping: 30 };\n\n/* ── Depth constants ── */\n\nconst MAX_VISIBLE = 4;\nconst DEPTH_Y = 7;\nconst DEPTH_SCALE = 0.035;\nconst DEPTH_OPACITY = 0.12;\nconst CARD_H = 78;\n\n/* ── Component ── */\n\nexport function Notification({\n items: ext,\n onDismiss,\n sound = true,\n}: NotificationProps) {\n const [internal, setInternal] = useState(\n () => ext ?? DEFAULT_ITEMS,\n );\n const [isDragging, setIsDragging] = useState(false);\n\n const items = ext ?? internal;\n const visible = items.slice(0, MAX_VISIBLE);\n\n const dismiss = useCallback(\n (id: string) => {\n if (sound) tick();\n if (ext) {\n onDismiss?.(id);\n } else {\n setInternal((prev) => prev.filter((n) => n.id !== id));\n onDismiss?.(id);\n }\n },\n [ext, onDismiss, sound],\n );\n\n const dotColor = (type?: \"default\" | \"success\" | \"error\"): string => {\n if (type === \"success\") return \"var(--nt-ok)\";\n if (type === \"error\") return \"var(--nt-err)\";\n return `rgba(var(--nt-ink),.35)`;\n };\n\n const stackH =\n visible.length > 0 ? CARD_H + (visible.length - 1) * DEPTH_Y : 0;\n\n return (\n \n