{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "bottom-drawers", "type": "registry:ui", "title": "Bottom Drawers", "description": "Snap-point bottom sheet — three snap heights (peek, half, full), drag between them, velocity-aware, progressive content reveal.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/bottom-drawers.tsx", "content": "\"use client\";\n\nimport { useRef, useState, type ReactNode } from \"react\";\nimport { motion } from \"motion/react\";\n\n/**\n * Bottom Drawers — snap-point bottom sheet.\n *\n * Three snap heights: peek, half, full. Drag between them.\n * Velocity-aware — flick to advance one snap. Spring physics.\n * Backdrop dims progressively. Tap outside to collapse.\n * Content reveals with opacity crossfades at each level.\n *\n * The mechanism IS the experience.\n */\n\n/* ── Types ── */\n\nexport interface BottomDrawersProps {\n /** Content visible at peek height (always visible) */\n peek: ReactNode;\n /** Content visible at half height and above */\n half?: ReactNode;\n /** Content visible at full height only */\n full?: ReactNode;\n /** Starting snap: 0 = peek, 1 = half, 2 = full */\n defaultSnap?: 0 | 1 | 2;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst DRAWER_H = 400;\nconst PEEK_H = 96;\nconst HALF_H = 240;\n\n/** Y offsets for each snap — higher value = more hidden */\nconst SNAPS = [\n DRAWER_H - PEEK_H, // peek: 304px down, 96px visible\n DRAWER_H - HALF_H, // half: 160px down, 240px visible\n 0, // full: 0px down, all 400px visible\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.0;\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 BottomDrawers({\n peek,\n half,\n full,\n defaultSnap = 0,\n sound = true,\n}: BottomDrawersProps) {\n const [snap, setSnap] = useState(defaultSnap);\n const lastSound = useRef(0);\n\n function goTo(target: 0 | 1 | 2) {\n if (target !== snap && sound) playTick(lastSound);\n setSnap(target);\n }\n\n function handleDragEnd(\n _: unknown,\n info: { offset: { y: number }; velocity: { y: number } },\n ) {\n const current = SNAPS[snap] + info.offset.y;\n const vel = info.velocity.y;\n\n let target: 0 | 1 | 2;\n if (vel > 300) {\n /* Flicking down → collapse toward peek */\n target = Math.max(snap - 1, 0) as 0 | 1 | 2;\n } else if (vel < -300) {\n /* Flicking up → expand toward full */\n target = Math.min(snap + 1, 2) as 0 | 1 | 2;\n } else {\n /* Find nearest snap by distance */\n target = SNAPS.reduce(\n (best, sy, i) =>\n Math.abs(current - sy) < Math.abs(current - SNAPS[best]) ? i : best,\n 0,\n ) as 0 | 1 | 2;\n }\n\n goTo(target);\n }\n\n /* Content visibility: peek always, half at snap>=1, full at snap===2 */\n const showHalf = snap >= 1;\n const showFull = snap === 2;\n\n /* Backdrop opacity: 0 at peek, progressive at half & full */\n const backdropOpacity = snap === 0 ? 0 : snap === 1 ? 0.15 : 0.35;\n\n return (\n <>\n \n\n {/* Backdrop — dims progressively, click to collapse */}\n goTo(0)}\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"black\",\n zIndex: 40,\n pointerEvents: snap > 0 ? \"auto\" : \"none\",\n }}\n />\n\n {/* Drawer */}\n \n {/* Handle */}\n \n \n \n\n {/* Peek content — always visible */}\n
{peek}
\n\n {/* Half content — fades in at half & full */}\n {half && (\n \n {half}\n \n )}\n\n {/* Full content — fades in at full only */}\n {full && (\n \n {full}\n \n )}\n \n \n );\n}\n\nexport default BottomDrawers;\n", "type": "registry:ui", "target": "components/ruixen/bottom-drawers.tsx" } ] }