{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"draggable-bottom-sheet","type":"registry:component","title":"Draggable Bottom Sheet Modal","description":"An iOS-style bottom sheet with peek, half and full detents: velocity-aware snapping, rubber-banding past the ends, a scrim that dims with progress, content that scrolls and then hands off to the drag, Escape and a focus trap. No dependencies.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/draggable-bottom-sheet.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\n\n/** How far the sheet may be pulled past its tallest snap point (it is rubber-banded), in px. */\nconst OVER = 96;\n/** Snap points as fractions of the container's height: peek, half, full. */\nconst DEFAULT_SNAPS = [0.3, 0.55, 0.92];\n\ntype Theme = \"light\" | \"dark\";\n\nconst VARS: Record<Theme, CSSProperties> = {\n  light: {\n    \"--bs-bg\": \"#ffffff\",\n    \"--bs-fg\": \"#0b0b0d\",\n    \"--bs-muted\": \"rgba(11,11,13,0.56)\",\n    \"--bs-line\": \"rgba(11,11,13,0.09)\",\n    \"--bs-grip\": \"rgba(11,11,13,0.22)\",\n    \"--bs-grip-hot\": \"rgba(11,11,13,0.42)\",\n    boxShadow: \"0 -1px 0 rgba(255,255,255,0.9) inset, 0 -18px 50px rgba(8,10,20,0.2), 0 -2px 8px rgba(8,10,20,0.08)\",\n    backgroundImage: \"linear-gradient(180deg, rgba(0,0,0,0.025), rgba(0,0,0,0) 90px)\",\n  } as CSSProperties,\n  dark: {\n    \"--bs-bg\": \"#111215\",\n    \"--bs-fg\": \"#f5f5f7\",\n    \"--bs-muted\": \"rgba(255,255,255,0.56)\",\n    \"--bs-line\": \"rgba(255,255,255,0.09)\",\n    \"--bs-grip\": \"rgba(255,255,255,0.26)\",\n    \"--bs-grip-hot\": \"rgba(255,255,255,0.5)\",\n    boxShadow: \"0 1px 0 rgba(255,255,255,0.1) inset, 0 -24px 64px rgba(0,0,0,0.6), 0 -2px 10px rgba(0,0,0,0.4)\",\n    backgroundImage: \"linear-gradient(180deg, rgba(255,255,255,0.055), rgba(255,255,255,0) 110px)\",\n  } as CSSProperties,\n};\n\ntype Sample = { t: number; raw: number; st: number };\ntype Drag = {\n  id: number;\n  y0: number;\n  lastY: number;\n  k: number;\n  raw: number;\n  startRaw: number;\n  mode: \"pending\" | \"active\";\n  inScroll: boolean;\n  sheetMoved: boolean;\n  hist: Sample[];\n};\ntype Ptr = { pointerId: number; clientY: number; timeStamp: number; type: string };\ntype Engine = {\n  v: number;\n  vel: number;\n  target: number;\n  H: number;\n  snaps: number[];\n  snap: number;\n  lastSnap: number;\n  shownSnap: number;\n  closing: boolean;\n  raf: number;\n  last: number;\n  reduced: boolean;\n  drag: Drag | null;\n  swallow: boolean;\n  mom: number;\n  wheelAt: number;\n  unlisten: (() => void) | null;\n};\n\nconst clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n));\n/** Rubber band: 1:1-ish at first, then it resists and asymptotes ~60px past the end. */\nconst band = (raw: number, full: number) => (raw <= full ? raw : full + 60 * (1 - Math.exp(-((raw - full) * 0.6) / 60)));\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\nconst SNAP_NAMES = [\"Peek\", \"Half\", \"Full\"];\n\nexport function BottomSheet({\n  open,\n  onClose,\n  title = \"Filters\",\n  children,\n  snapPoints = DEFAULT_SNAPS,\n  defaultSnap = 1,\n  onSnapChange,\n  contained = false,\n  theme = \"light\",\n  dismissible = true,\n  className = \"\",\n}: {\n  open: boolean;\n  onClose: () => void;\n  title?: string;\n  children?: ReactNode;\n  /** Heights the sheet rests at, as fractions (0-1) of the container: smallest first. */\n  snapPoints?: number[];\n  /** Index into snapPoints it opens at. */\n  defaultSnap?: number;\n  onSnapChange?: (index: number) => void;\n  /** Position inside its nearest positioned ancestor instead of covering the viewport. */\n  contained?: boolean;\n  theme?: Theme;\n  /** When false the sheet only moves between snap points and can't be closed by drag, scrim or Escape. */\n  dismissible?: boolean;\n  className?: string;\n}) {\n  const snapKey = snapPoints.join(\",\");\n  const count = snapPoints.length || 1;\n  const defIdx = clamp(Math.round(defaultSnap), 0, count - 1);\n  const uid = useId();\n  const titleId = uid + \"-t\";\n\n  const [shown, setShown] = useState(open);\n  if (open && !shown) setShown(true);\n  const [snapState, setSnapState] = useState(defIdx);\n  const [dragging, setDragging] = useState(false);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const sheetRef = useRef<HTMLDivElement>(null);\n  const backRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const wakeRef = useRef<() => void>(() => {});\n  const moveRef = useRef<(e: Ptr) => void>(() => {});\n  const endRef = useRef<(e: Ptr) => void>(() => {});\n  const goRef = useRef<(i: number) => void>(() => {});\n  const restoreRef = useRef<HTMLElement | null>(null);\n  const live = useRef({ open, onClose, onSnapChange, dismissible, snapKey, defIdx, count });\n  const eng = useRef<Engine>({\n    v: 0,\n    vel: 0,\n    target: 0,\n    H: 0,\n    snaps: [],\n    snap: -1,\n    lastSnap: -1,\n    shownSnap: -2,\n    closing: false,\n    raf: 0,\n    last: 0,\n    reduced: false,\n    drag: null,\n    swallow: false,\n    mom: 0,\n    wheelAt: 0,\n    unlisten: null,\n  });\n\n  useEffect(() => {\n    live.current = { open, onClose, onSnapChange, dismissible, snapKey, defIdx, count };\n  });\n\n  // The engine: one rAF loop that springs the sheet toward its target and sleeps when settled.\n  useEffect(() => {\n    if (!shown) return;\n    const root = rootRef.current;\n    if (!root) return;\n    const E = eng.current;\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onMq = () => {\n      E.reduced = mq.matches;\n    };\n    onMq();\n    mq.addEventListener(\"change\", onMq);\n\n    const full = () => E.snaps[E.snaps.length - 1] ?? 0;\n    const wake = () => {\n      if (!E.raf) {\n        E.last = 0;\n        E.raf = requestAnimationFrame(tick);\n      }\n    };\n    wakeRef.current = wake;\n\n    const apply = () => {\n      const s = sheetRef.current;\n      if (!s || !E.H) return;\n      const f = full();\n      s.style.transform = \"translate3d(0,\" + (f + OVER - E.v).toFixed(2) + \"px,0)\";\n      s.style.visibility = \"visible\";\n      const b = backRef.current;\n      if (b) b.style.opacity = (0.62 * Math.pow(clamp(E.v / (f || 1), 0, 1), 1.15)).toFixed(3);\n    };\n\n    const goTo = (i: number) => {\n      const n = E.snaps.length;\n      if (!n) return;\n      const idx = clamp(i, 0, n - 1);\n      E.snap = idx;\n      E.lastSnap = idx;\n      E.target = E.snaps[idx] ?? 0;\n      E.closing = false;\n      wake();\n    };\n    goRef.current = goTo;\n\n    const measure = () => {\n      const H = root.clientHeight;\n      E.H = H;\n      const fr = (live.current.snapKey || \"0.5\")\n        .split(\",\")\n        .map((x) => clamp(parseFloat(x) || 0.5, 0.05, 1))\n        .sort((a, b) => a - b);\n      E.snaps = fr.map((f) => f * H);\n      const s = sheetRef.current;\n      if (s) s.style.height = full() + OVER + \"px\";\n      if (E.snap >= 0 && !E.drag) E.target = E.snaps[Math.min(E.snap, E.snaps.length - 1)] ?? 0;\n      wake();\n    };\n\n    const tick = (now: number) => {\n      E.raf = 0;\n      const dt = E.last ? Math.min(0.032, (now - E.last) / 1000) : 1 / 60;\n      E.last = now;\n      let busy = false;\n\n      const d = E.drag;\n      if (d && d.mode === \"active\") {\n        busy = true;\n      } else {\n        const k = E.reduced ? 640 : 300;\n        const c = 2 * (E.reduced ? 1 : 0.88) * Math.sqrt(k);\n        const steps = 3;\n        const h = dt / steps;\n        for (let i = 0; i < steps; i++) {\n          E.vel += (-k * (E.v - E.target) - c * E.vel) * h;\n          E.v += E.vel * h;\n        }\n        if (E.v < 0 && E.target === 0) {\n          E.v = 0;\n          E.vel = 0;\n        }\n        if (Math.abs(E.v - E.target) < 0.3 && Math.abs(E.vel) < 8) {\n          E.v = E.target;\n          E.vel = 0;\n        } else busy = true;\n      }\n\n      if (E.mom) {\n        const sc = scrollRef.current;\n        if (sc) {\n          const before = sc.scrollTop;\n          sc.scrollTop = before + E.mom * dt;\n          E.mom *= Math.exp(-dt * 3.4);\n          if (Math.abs(E.mom) < 18 || sc.scrollTop === before) E.mom = 0;\n        } else E.mom = 0;\n        if (E.mom) busy = true;\n      }\n\n      apply();\n\n      if (E.snap !== E.shownSnap) {\n        E.shownSnap = E.snap;\n        setSnapState(E.snap);\n        if (E.snap >= 0) live.current.onSnapChange?.(E.snap);\n      }\n\n      if (!busy && E.v === 0 && E.target === 0) {\n        if (E.closing) {\n          E.closing = false;\n          E.snap = -1;\n          E.shownSnap = -2;\n          setShown(false);\n          return;\n        }\n        if (live.current.open && !E.drag) {\n          // The parent kept the sheet open after a dismissal: bring it back.\n          goTo(E.lastSnap >= 0 ? E.lastSnap : live.current.defIdx);\n          return;\n        }\n      }\n      if (busy) E.raf = requestAnimationFrame(tick);\n    };\n\n    const ro = new ResizeObserver(measure);\n    ro.observe(root);\n    measure();\n\n    // Wheel: on a trackpad or mouse the sheet steps between detents, then the content scrolls.\n    const sheet = sheetRef.current;\n    const onWheel = (e: WheelEvent) => {\n      if (E.drag || Math.abs(e.deltaY) < 10) return;\n      const sc = scrollRef.current;\n      const n = E.snaps.length;\n      if (E.snap < 0 || n < 2) return;\n      const atTop = !sc || sc.scrollTop <= 0;\n      const stepUp = e.deltaY > 0 && E.snap < n - 1;\n      const stepDown = e.deltaY < 0 && atTop && E.snap > 0;\n      if (!stepUp && !stepDown) return;\n      e.preventDefault();\n      if (e.timeStamp - E.wheelAt < 520) return;\n      E.wheelAt = e.timeStamp;\n      goTo(E.snap + (stepUp ? 1 : -1));\n    };\n    sheet?.addEventListener(\"wheel\", onWheel, { passive: false });\n\n    return () => {\n      cancelAnimationFrame(E.raf);\n      E.raf = 0;\n      E.unlisten?.();\n      E.unlisten = null;\n      ro.disconnect();\n      mq.removeEventListener(\"change\", onMq);\n      sheet?.removeEventListener(\"wheel\", onWheel);\n    };\n  }, [shown, snapKey]);\n\n  // Open / close from the prop.\n  useEffect(() => {\n    const E = eng.current;\n    if (open) {\n      E.closing = false;\n      E.drag = null;\n      goRef.current(defIdx);\n      if (defIdx > 0 || count === 1) {\n        restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n        sheetRef.current?.focus({ preventScroll: true });\n      }\n    } else if (E.snap >= 0 || E.v > 0) {\n      E.closing = true;\n      E.target = 0;\n      E.snap = -1;\n      wakeRef.current();\n      const r = restoreRef.current;\n      restoreRef.current = null;\n      if (r && document.contains(r) && sheetRef.current?.contains(document.activeElement)) r.focus({ preventScroll: true });\n    }\n    // Only the open flag drives this; defIdx is read when the sheet opens.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open]);\n\n  const modal = open && (count === 1 || snapState > 0);\n\n  // Escape closes while the sheet is modal or holds focus.\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || !live.current.dismissible) return;\n      if (modal || sheetRef.current?.contains(document.activeElement)) {\n        e.stopPropagation();\n        live.current.onClose();\n      }\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, modal]);\n\n  const dismiss = () => {\n    if (live.current.dismissible) live.current.onClose();\n  };\n\n  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const E = eng.current;\n    if (E.closing || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    const t = e.target as HTMLElement;\n    if (t.closest(\"input,textarea,select,[contenteditable='true'],[data-no-drag]\")) return;\n    const sheet = sheetRef.current;\n    if (!sheet) return;\n    const rect = sheet.getBoundingClientRect();\n    const k = rect.height ? sheet.offsetHeight / rect.height : 1;\n    const sc = scrollRef.current;\n    E.mom = 0;\n    E.swallow = false;\n    E.vel = 0;\n    E.drag = {\n      id: e.pointerId,\n      y0: e.clientY,\n      lastY: e.clientY,\n      k,\n      raw: E.v,\n      startRaw: E.v,\n      mode: \"pending\",\n      inScroll: !!sc && sc.contains(t),\n      sheetMoved: false,\n      hist: [{ t: e.timeStamp, raw: E.v, st: sc ? sc.scrollTop : 0 }],\n    };\n    // Window listeners instead of pointer capture: capture would retarget the click and break buttons inside the sheet.\n    E.unlisten?.();\n    const mv = (ev: PointerEvent) => moveRef.current(ev);\n    const up = (ev: PointerEvent) => endRef.current(ev);\n    window.addEventListener(\"pointermove\", mv);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", up);\n    E.unlisten = () => {\n      window.removeEventListener(\"pointermove\", mv);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", up);\n    };\n  };\n\n  const onPointerMove = (e: Ptr) => {\n    const E = eng.current;\n    const d = E.drag;\n    if (!d || d.id !== e.pointerId) return;\n    if (d.mode === \"pending\") {\n      if (Math.abs(e.clientY - d.y0) * d.k < 6) return;\n      d.mode = \"active\";\n      E.swallow = true;\n      setDragging(true);\n      wakeRef.current();\n    }\n    let rem = (e.clientY - d.lastY) * d.k;\n    d.lastY = e.clientY;\n    const f = E.snaps[E.snaps.length - 1] ?? 0;\n    const sc = scrollRef.current;\n    if (rem > 0 && d.raw > f) {\n      const use = Math.min(rem, d.raw - f);\n      d.raw -= use;\n      rem -= use;\n      d.sheetMoved = true;\n    }\n    if (d.inScroll && sc) {\n      if (rem > 0 && sc.scrollTop > 0) {\n        const use = Math.min(rem, sc.scrollTop);\n        sc.scrollTop -= use;\n        rem -= use;\n      } else if (rem < 0 && d.raw >= f - 0.5) {\n        const room = sc.scrollHeight - sc.clientHeight - sc.scrollTop;\n        const use = Math.min(-rem, Math.max(0, room));\n        sc.scrollTop += use;\n        rem += use;\n      }\n    }\n    if (rem !== 0) {\n      d.raw = Math.max(0, d.raw - rem);\n      d.sheetMoved = true;\n    }\n    E.v = band(d.raw, f);\n    d.hist.push({ t: e.timeStamp, raw: d.raw, st: sc ? sc.scrollTop : 0 });\n    if (d.hist.length > 12) d.hist.shift();\n  };\n\n  const onPointerEnd = (e: Ptr) => {\n    eng.current.unlisten?.();\n    eng.current.unlisten = null;\n    const E = eng.current;\n    const d = E.drag;\n    if (!d || d.id !== e.pointerId) return;\n    E.drag = null;\n    setDragging(false);\n    if (d.mode !== \"active\") return;\n    const last = d.hist[d.hist.length - 1];\n    let first = d.hist[0];\n    for (const s of d.hist) {\n      if (last && last.t - s.t <= 90) {\n        first = s;\n        break;\n      }\n    }\n    const span = last && first ? Math.max(16, last.t - first.t) / 1000 : 0.016;\n    const stale = last ? e.timeStamp - last.t > 110 : false;\n    const sheetVel = !stale && last && first ? (last.raw - first.raw) / span : 0;\n    const scrollVel = !stale && last && first ? -(last.st - first.st) / span : 0;\n    if (e.type === \"pointercancel\" && !d.sheetMoved) {\n      wakeRef.current();\n      return;\n    }\n    if (!d.sheetMoved) {\n      // Pure content scroll: let it coast.\n      E.mom = scrollVel;\n      E.vel = 0;\n      E.target = E.snaps[E.snap] ?? E.target;\n      wakeRef.current();\n      return;\n    }\n    const proj = E.v + sheetVel * 0.2;\n    const cands = live.current.dismissible ? [0, ...E.snaps] : E.snaps;\n    let best = cands[0] ?? 0;\n    for (const c of cands) if (Math.abs(c - proj) < Math.abs(best - proj)) best = c;\n    if (Math.abs(d.raw - d.startRaw) < 8 && Math.abs(sheetVel) < 200) {\n      best = E.snaps[E.snap] ?? best;\n    }\n    E.vel = E.v > (E.snaps[E.snaps.length - 1] ?? 0) ? 0 : sheetVel;\n    if (best === 0 && live.current.dismissible) {\n      E.target = 0;\n      live.current.onClose();\n      wakeRef.current();\n    } else {\n      const idx = E.snaps.indexOf(best);\n      if (idx >= 0) goRef.current(idx);\n      const ideal = E.snaps[idx] ?? best;\n      E.vel = Math.abs(E.v - ideal) < 1 ? 0 : E.vel;\n    }\n  };\n\n  useEffect(() => {\n    moveRef.current = onPointerMove;\n    endRef.current = onPointerEnd;\n  });\n\n  const onClickCapture = (e: { preventDefault: () => void; stopPropagation: () => void }) => {\n    const E = eng.current;\n    if (E.swallow) {\n      E.swallow = false;\n      e.preventDefault();\n      e.stopPropagation();\n    }\n  };\n\n  const onSheetKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (e.key !== \"Tab\" || !modal) return;\n    const sheet = sheetRef.current;\n    if (!sheet) return;\n    const items = Array.from(sheet.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.offsetParent !== null || el === document.activeElement);\n    const first = items[0];\n    const lastEl = items[items.length - 1];\n    if (!first || !lastEl) {\n      e.preventDefault();\n      sheet.focus();\n      return;\n    }\n    const a = document.activeElement;\n    if (e.shiftKey && (a === first || a === sheet)) {\n      e.preventDefault();\n      lastEl.focus();\n    } else if (!e.shiftKey && a === lastEl) {\n      e.preventDefault();\n      first.focus();\n    }\n  };\n\n  const onGripKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    const E = eng.current;\n    const cur = E.snap < 0 ? snapState : E.snap;\n    let next = cur;\n    if (e.key === \"ArrowUp\" || e.key === \"PageUp\") next = cur + 1;\n    else if (e.key === \"ArrowDown\" || e.key === \"PageDown\") next = cur - 1;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = count - 1;\n    else if (e.key === \"Enter\" || e.key === \" \") next = cur >= count - 1 ? 0 : cur + 1;\n    else return;\n    e.preventDefault();\n    goRef.current(clamp(next, 0, count - 1));\n  };\n\n  const onGripClick = () => {\n    const E = eng.current;\n    if (E.swallow) return;\n    const cur = E.snap < 0 ? snapState : E.snap;\n    goRef.current(cur >= count - 1 ? 0 : cur + 1);\n  };\n\n  if (!shown) return null;\n\n  const name = (i: number) => (count === 3 ? SNAP_NAMES[i] : undefined) ?? \"Snap \" + (i + 1);\n\n  return (\n    <div\n      ref={rootRef}\n      className={(contained ? \"absolute\" : \"fixed\") + \" inset-0 z-50 overflow-hidden \" + className}\n      style={{ pointerEvents: \"none\" }}\n    >\n      <div\n        ref={backRef}\n        aria-hidden=\"true\"\n        onClick={dismiss}\n        className=\"absolute inset-0 bg-black\"\n        style={{ opacity: 0, pointerEvents: modal ? \"auto\" : \"none\" }}\n      />\n      <div\n        ref={sheetRef}\n        role=\"dialog\"\n        aria-modal={modal}\n        aria-labelledby={titleId}\n        tabIndex={-1}\n        onPointerDown={onPointerDown}\n        onClickCapture={onClickCapture}\n        onKeyDown={onSheetKeyDown}\n        className=\"absolute inset-x-0 bottom-0 flex select-none flex-col rounded-t-[26px] focus:outline-none\"\n        style={{\n          ...VARS[theme],\n          backgroundColor: \"var(--bs-bg)\",\n          color: \"var(--bs-fg)\",\n          touchAction: \"none\",\n          pointerEvents: \"auto\",\n          willChange: \"transform\",\n          visibility: \"hidden\",\n          transform: \"translate3d(0,100%,0)\",\n          cursor: dragging ? \"grabbing\" : \"grab\",\n        }}\n      >\n        <div\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Sheet height\"\n          aria-orientation=\"vertical\"\n          aria-valuemin={1}\n          aria-valuemax={count}\n          aria-valuenow={snapState + 1}\n          aria-valuetext={name(Math.max(0, snapState))}\n          onKeyDown={onGripKey}\n          onClick={onGripClick}\n          className=\"group flex h-7 shrink-0 items-center justify-center focus-visible:!rounded-t-[22px] focus-visible:!outline-2 focus-visible:!outline-offset-[-3px]\"\n          style={{ outlineColor: \"#5b8cff\" }}\n        >\n          <span\n            aria-hidden=\"true\"\n            className=\"block h-[5px] rounded-full transition-[width,background-color] duration-200 ease-out\"\n            style={{ width: dragging ? 48 : 38, background: dragging ? \"var(--bs-grip-hot)\" : \"var(--bs-grip)\" }}\n          />\n        </div>\n        <div className=\"flex shrink-0 items-center justify-between gap-3 px-5 pb-3\">\n          <h2 id={titleId} className=\"m-0 min-w-0 truncate text-[19px] font-semibold leading-7 tracking-[-0.02em]\">\n            {title}\n          </h2>\n          {dismissible && (\n            <button\n              type=\"button\"\n              aria-label=\"Close\"\n              onClick={dismiss}\n              className=\"-mr-2 flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-colors duration-150 focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-0\"\n              style={{ color: \"var(--bs-muted)\", outlineColor: \"#5b8cff\", cursor: \"pointer\" }}\n            >\n              <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" width=\"15\" height=\"15\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\">\n                <path d=\"M3.5 3.5l9 9M12.5 3.5l-9 9\" />\n              </svg>\n            </button>\n          )}\n        </div>\n        <div ref={scrollRef} className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 text-[15px]\" style={{ paddingBottom: OVER + 24, touchAction: \"none\", borderTop: \"1px solid var(--bs-line)\", paddingTop: 16 }}>\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/draggable-bottom-sheet.tsx"}],"meta":{"tier":"free","tags":["modal","sheet","drag","dismiss","snap","detents","mobile","spring","gesture"],"href":"https://viberdy.dev/library/draggable-bottom-sheet","aiPrompt":"Create a React + Tailwind (v3.4 with arbitrary values, or v4) bottom sheet component named BottomSheet in one self-contained \"use client\" file. React only, no animation library. It is an iOS-style sheet with snap points, driven by one hand-written spring and a pointer state machine.\n\nProps: open (boolean, controlled), onClose, title = \"Filters\", children, snapPoints = [0.3, 0.55, 0.92] (fractions of the container height, smallest first: peek, half, full), defaultSnap = 1 (index it opens at), onSnapChange(index), contained = false (absolute inside the nearest positioned ancestor instead of fixed over the viewport), theme = \"light\" | \"dark\" (default \"light\": white sheet; dark: #111215), dismissible = true, className (on the root).\n\nStructure. Root: fixed (or absolute when contained) inset-0 z-50 overflow-hidden, pointer-events none. Inside: a black scrim (absolute inset-0, aria-hidden, onClick = close) and the sheet (absolute inset-x-0 bottom-0, flex column, rounded-t-[26px], role dialog, aria-labelledby the title, tabIndex -1, touch-action none, will-change transform). The sheet is as tall as the largest snap plus 96px of overscroll room; it is moved only with translate3d(0, (fullSnap + 96 - v)px, 0) where v is the visible height in px, so layout never changes during a drag. Theme variables on the sheet: --bs-bg, --bs-fg, --bs-muted, --bs-line, --bs-grip, --bs-grip-hot (light: #fff, #0b0b0d, 56% / 9% / 22% / 42% of it; dark: #111215, #f5f5f7, white at 56% / 9% / 26% / 50%) plus a top highlight (dark: inset 0 1px white 10%) and a soft upward shadow. Header: a 28px-tall grip strip with a 38x5 pill (48 wide and brighter while dragging; 200ms ease on width and colour), then a row with the title (h2, 19px, 600, tracking -0.02em, truncates) and a 40x40 round close button (hidden when not dismissible) with an SVG X. Below, a scroll area (flex-1, min-h-0, overflow-y auto, overscroll contain, px 20, top border 1px --bs-line, padding-bottom 120px) holding children.\n\nEngine. State lives in one ref (v, vel, target, snaps in px, snap index, drag, momentum). One rAF loop in a mount effect (re-created when the sheet mounts or snapPoints change) that sleeps when nothing moves. Spring toward target with semi-implicit Euler, 3 substeps per frame, dt clamped to 32ms: stiffness 300, damping ratio 0.88 (c = 2 * 0.88 * sqrt(300)). Every frame write the sheet transform and the scrim opacity = 0.62 * clamp(v / fullSnap, 0, 1)^1.15. The scrim only receives pointer events when the sheet is modal (above the lowest snap, or only one snap exists), so a peeking sheet leaves the page usable. Open: mount with v = 0 (sheet hidden below, visibility hidden until measured) and spring to snaps[defaultSnap]. Close (open becomes false): spring to 0, then unmount. If the parent keeps open true after a dismissal gesture, the sheet springs back to its last snap. Measure the root with a ResizeObserver (clientHeight, layout pixels) and retarget on resize. Mount-open uses the \"adjust state during render\" pattern (if (open && !shown) setShown(true)) so no setState runs synchronously in an effect.\n\nGestures (pointer events on the sheet, touch-action none, pointer capture only after 6px of travel, coordinates scaled by offsetHeight / getBoundingClientRect().height so it works inside a scaled preview). Ignore pointerdown on input, textarea, select, [contenteditable], [data-no-drag]. Track raw (the unbanded visible height) from the start. For each move delta: first, if the finger moves down and raw is above the top snap, undo overscroll. If the drag began in the scroll area: finger down with scrollTop > 0 scrolls the content (scrollTop -= delta); finger up while raw >= fullSnap scrolls the content up to its end. Whatever remains moves the sheet: raw -= remaining, floored at 0; v = band(raw) where band(raw) = raw up to the top snap and, beyond it, top + 60 * (1 - exp(-(raw - top) * 0.6 / 60)) (rubber band). Record the last 12 (time, raw, scrollTop) samples. A drag that went past 6px swallows the following click (capture phase) so releasing over a button does not fire it. On release: if only the content scrolled, let it coast (momentum on scrollTop, decaying exp(-3.4 t), stop under 18px/s). Otherwise take the velocity over the last 90ms (0 if the last sample is older than 110ms), project p = v + velocity * 0.2, pick the nearest of [0 (only when dismissible), ...snaps] to p, and start the spring from the release velocity. A drag shorter than 8px with velocity under 200px/s returns to the starting snap. Choosing 0 calls onClose. Wheel on a mouse or trackpad steps one snap per 520ms (deltaY > 10) up, or down when the content is scrolled to the top; otherwise the content scrolls natively.\n\nAccessibility. Dialog with aria-modal only while modal. When it opens modal, remember document.activeElement, focus the sheet, trap Tab and Shift+Tab inside, and restore focus on close. Escape (document listener) calls onClose when dismissible and the sheet is modal or holds focus. The grip strip is role slider (aria-label \"Sheet height\", orientation vertical, valuemin 1, valuemax = snap count, valuenow, valuetext Peek / Half / Full for three snaps), tabIndex 0, with ArrowUp/PageUp next detent, ArrowDown/PageDown previous, Home, End, Enter/Space cycle; a click or tap on it cycles up and wraps to the lowest. Focus rings are 2px #5b8cff outlines (use important utilities if your CSS resets outline). Touch targets 40px or more. Reduced motion (matchMedia, prefers-reduced-motion): critically damped spring (ratio 1, stiffness 640), no bounce.\n\nPaint everything with explicit colours or CSS variables; no site tokens. Demo content is yours to supply through children; keep it vertical-only because touch-action none disables native horizontal panning inside the sheet.","license":"https://viberdy.dev/license"}}