{
  "$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<number>) {\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      <style>{`\n        .bd{--d-ink:0,0,0;--d-bg:rgba(255,255,255,0.98)}\n        .dark .bd,[data-theme=\"dark\"] .bd{--d-ink:255,255,255;--d-bg:rgba(22,22,24,0.98)}\n      `}</style>\n\n      {/* Backdrop — dims progressively, click to collapse */}\n      <motion.div\n        animate={{ opacity: backdropOpacity }}\n        initial={{ opacity: 0 }}\n        transition={{ duration: 0.25 }}\n        onClick={() => 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      <motion.div\n        className=\"bd\"\n        drag=\"y\"\n        dragConstraints={{ top: 0, bottom: SNAPS[0] }}\n        dragElastic={0.05}\n        onDragEnd={handleDragEnd}\n        initial={{ y: SNAPS[defaultSnap] }}\n        animate={{ y: SNAPS[snap] }}\n        transition={{ type: \"spring\", damping: 32, stiffness: 380 }}\n        style={{\n          position: \"absolute\",\n          bottom: 0,\n          left: 0,\n          right: 0,\n          height: DRAWER_H,\n          background: \"var(--d-bg)\",\n          borderTop: \"1px solid rgba(var(--d-ink),0.06)\",\n          borderRadius: \"14px 14px 0 0\",\n          touchAction: \"none\",\n          zIndex: 50,\n          overflow: \"hidden\",\n          cursor: \"grab\",\n        }}\n      >\n        {/* Handle */}\n        <div\n          style={{\n            display: \"flex\",\n            justifyContent: \"center\",\n            padding: \"10px 0 6px\",\n          }}\n        >\n          <div\n            style={{\n              width: 32,\n              height: 3.5,\n              borderRadius: 2,\n              background: \"rgba(var(--d-ink),0.10)\",\n            }}\n          />\n        </div>\n\n        {/* Peek content — always visible */}\n        <div style={{ padding: \"0 20px\" }}>{peek}</div>\n\n        {/* Half content — fades in at half & full */}\n        {half && (\n          <motion.div\n            animate={{ opacity: showHalf ? 1 : 0 }}\n            transition={{ duration: 0.2 }}\n            style={{\n              padding: \"0 20px\",\n              pointerEvents: showHalf ? \"auto\" : \"none\",\n            }}\n          >\n            {half}\n          </motion.div>\n        )}\n\n        {/* Full content — fades in at full only */}\n        {full && (\n          <motion.div\n            animate={{ opacity: showFull ? 1 : 0 }}\n            transition={{ duration: 0.2 }}\n            style={{\n              padding: \"0 20px\",\n              pointerEvents: showFull ? \"auto\" : \"none\",\n            }}\n          >\n            {full}\n          </motion.div>\n        )}\n      </motion.div>\n    </>\n  );\n}\n\nexport default BottomDrawers;\n",
      "type": "registry:ui",
      "target": "components/ruixen/bottom-drawers.tsx"
    }
  ]
}