{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "drawer-inner-content", "type": "registry:ui", "title": "Drawer Inner Content", "description": "Gesture-driven bottom sheet — drag to dismiss, spring physics, structured content sections with staggered cascade.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/drawer-inner-content.tsx", "content": "\"use client\";\n\nimport { useRef, type ReactNode } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/**\n * Drawer Inner Content — gesture-driven bottom sheet.\n *\n * Built from nothing. No vaul, no shadcn, no portal.\n * Spring physics for open / close. Drag to dismiss —\n * velocity-aware snap. Content cascades in on open.\n * Structured sections with hairline separators.\n *\n * The drawer is infrastructure. The content is the design.\n */\n\n/* ── Types ── */\n\nexport interface DrawerItem {\n icon: ReactNode;\n label: string;\n onClick?: () => void;\n danger?: boolean;\n}\n\nexport interface DrawerSection {\n title?: string;\n items: DrawerItem[];\n}\n\nexport interface DrawerInnerContentProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n sections: DrawerSection[];\n sound?: boolean;\n}\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction audioCtx() {\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 rate = ac.sampleRate;\n const len = Math.floor(rate * 0.003);\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n _buf = buf;\n return buf;\n}\n\nfunction playTick(last: React.MutableRefObject) {\n const now = performance.now();\n if (now - last.current < 80) return;\n last.current = now;\n try {\n const ac = audioCtx();\n const buf = ensureBuf(ac);\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n src.buffer = buf;\n src.playbackRate.value = 1.1;\n gain.gain.value = 0.03;\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Component ── */\n\nexport function DrawerInnerContent({\n open,\n onOpenChange,\n sections,\n sound = true,\n}: DrawerInnerContentProps) {\n const lastSound = useRef(0);\n\n /* Flatten items for stagger index */\n let globalIndex = 0;\n\n return (\n <>\n \n \n {open && (\n <>\n {/* Backdrop */}\n onOpenChange(false)}\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"rgba(0,0,0,0.4)\",\n zIndex: 40,\n }}\n />\n\n {/* Drawer */}\n {\n if (info.velocity.y > 300 || info.offset.y > 100) {\n onOpenChange(false);\n }\n }}\n style={{\n position: \"absolute\",\n bottom: 0,\n left: 0,\n right: 0,\n zIndex: 50,\n background: \"var(--d-bg)\",\n borderTop: \"1px solid rgba(var(--d-ink),0.06)\",\n borderRadius: \"16px 16px 0 0\",\n paddingBottom: 20,\n touchAction: \"none\",\n }}\n >\n {/* Handle */}\n \n \n \n\n {/* Sections */}\n {sections.map((section, si) => {\n const sectionItems = section.items.map((item, ii) => {\n const idx = globalIndex++;\n return (\n {\n if (sound) playTick(lastSound);\n item.onClick?.();\n }}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: \"11px 20px\",\n cursor: \"pointer\",\n fontSize: 14,\n fontWeight: 450,\n letterSpacing: \"-0.01em\",\n color: item.danger\n ? \"rgba(255,69,58,0.75)\"\n : \"rgba(var(--d-ink),0.55)\",\n transition: \"background 0.12s, color 0.12s\",\n }}\n onMouseEnter={(e) => {\n e.currentTarget.style.background =\n \"rgba(var(--d-ink),0.04)\";\n e.currentTarget.style.color = item.danger\n ? \"rgba(255,69,58,1)\"\n : \"rgba(var(--d-ink),0.85)\";\n }}\n onMouseLeave={(e) => {\n e.currentTarget.style.background = \"transparent\";\n e.currentTarget.style.color = item.danger\n ? \"rgba(255,69,58,0.75)\"\n : \"rgba(var(--d-ink),0.55)\";\n }}\n >\n \n {item.icon}\n \n {item.label}\n \n );\n });\n\n return (\n
\n {/* Section title */}\n {section.title && (\n \n {section.title}\n \n )}\n\n {/* Hairline */}\n {si > 0 && !section.title && (\n \n )}\n\n {/* Items */}\n {sectionItems}\n
\n );\n })}\n \n \n )}\n
\n \n );\n}\n\nexport default DrawerInnerContent;\n", "type": "registry:ui", "target": "components/ruixen/drawer-inner-content.tsx" } ] }