{"slug":"bottom-sheet","name":"Bottom Sheet","description":"Vaul-inspired draggable bottom sheet with snap points, inertia and glass surface.","category":"motion","source_url":"https://beui.saura3h.xyz/r/bottom-sheet/raw","detail_url":"https://beui.saura3h.xyz/r/bottom-sheet","raw_url":"https://beui.saura3h.xyz/r/bottom-sheet/raw","page_url":"https://beui.saura3h.xyz/components/motion/bottom-sheet","dependencies":["motion","react"],"internal":["@/components/motion/bottom-sheet","@/lib/utils"],"files":[{"path":"components/motion/bottom-sheet.tsx","type":"component","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useDragControls,\n  useMotionValue,\n  useReducedMotion,\n  type PanInfo,\n} from \"motion/react\";\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface BottomSheetProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  /** Heights (0-1 = fraction of viewport, or \"auto\"). First entry is default. */\n  snapPoints?: (number | \"auto\")[];\n  defaultSnap?: number;\n  title?: string;\n  description?: string;\n  children?: ReactNode;\n  className?: string;\n  /** Min drag distance (px) past current snap to dismiss. */\n  dismissThreshold?: number;\n}\n\nexport function BottomSheet({\n  open,\n  onOpenChange,\n  snapPoints = [0.5, 0.92],\n  defaultSnap = 0,\n  title,\n  description,\n  children,\n  className,\n  dismissThreshold = 120,\n}: BottomSheetProps) {\n  const [snap, setSnap] = useState(defaultSnap);\n  const dragY = useMotionValue(0);\n  const dragControls = useDragControls();\n  const sheetRef = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const heightRef = useRef(0);\n\n  useEffect(() => {\n    if (open) setSnap(defaultSnap);\n  }, [open, defaultSnap]);\n\n  // Lock background scroll while open.\n  useEffect(() => {\n    if (!open) return;\n    const prev = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = prev;\n    };\n  }, [open]);\n\n  const onDragEnd = (_: unknown, info: PanInfo) => {\n    const velocity = info.velocity.y;\n    const offset = info.offset.y;\n\n    // Strong downward fling or large drag → dismiss.\n    if (velocity > 600 || offset > dismissThreshold) {\n      const smaller = snapPoints.map((_, i) => i).filter((i) => i < snap);\n      if (smaller.length && velocity < 800 && offset < dismissThreshold * 1.6) {\n        setSnap(smaller[smaller.length - 1]);\n      } else {\n        onOpenChange(false);\n      }\n      dragY.set(0);\n      return;\n    }\n\n    // Strong upward fling → next snap.\n    if (velocity < -500) {\n      setSnap(Math.min(snapPoints.length - 1, snap + 1));\n      dragY.set(0);\n      return;\n    }\n\n    // Otherwise snap to nearest by current offset.\n    if (offset > 80 && snap > 0) setSnap(snap - 1);\n    else if (offset < -80 && snap < snapPoints.length - 1) setSnap(snap + 1);\n    dragY.set(0);\n  };\n\n  const snapValue = snapPoints[snap];\n  const heightStyle =\n    snapValue === \"auto\" ? { maxHeight: \"92vh\" } : { height: `${snapValue * 100}vh` };\n\n  return (\n    <AnimatePresence>\n      {open ? (\n        <div className=\"pointer-events-none fixed inset-0 z-50\">\n          <motion.div\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}\n            onClick={() => onOpenChange(false)}\n            className=\"pointer-events-auto absolute inset-0 bg-black/50 backdrop-blur-xl backdrop-saturate-150\"\n          />\n          <motion.div\n            ref={sheetRef}\n            drag=\"y\"\n            dragControls={dragControls}\n            dragListener={false}\n            dragConstraints={{ top: 0, bottom: 0 }}\n            dragElastic={{ top: 0.02, bottom: 0.4 }}\n            dragMomentum={false}\n            onDrag={(_, info) => dragY.set(Math.max(0, info.offset.y))}\n            onDragEnd={onDragEnd}\n            initial={reduce ? { y: 0, opacity: 0 } : { y: \"100%\" }}\n            animate={reduce ? { y: 0, opacity: 1 } : { y: 0 }}\n            exit={reduce ? { y: 0, opacity: 0 } : { y: \"100%\" }}\n            transition={\n              reduce\n                ? { duration: 0.18, ease: [0.16, 1, 0.3, 1] }\n                : { type: \"spring\", stiffness: 420, damping: 40, mass: 0.5 }\n            }\n            onAnimationComplete={() => {\n              if (sheetRef.current) heightRef.current = sheetRef.current.offsetHeight;\n            }}\n            style={heightStyle}\n            className={cn(\n              \"pointer-events-auto absolute bottom-0 left-0 right-0 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-t-3xl will-change-transform\",\n              \"border border-(--color-border-strong) bg-(--color-bg-elev) shadow-[0_-24px_60px_-12px_rgb(0_0_0/0.45)]\",\n              className,\n            )}\n            role=\"dialog\"\n            aria-modal=\"true\"\n            aria-label={title}\n          >\n            <div\n              onPointerDown={(e) => dragControls.start(e)}\n              className=\"flex cursor-grab touch-none flex-col items-center px-4 pb-2 pt-3 active:cursor-grabbing\"\n            >\n              <div className=\"h-1.5 w-10 rounded-full bg-(--color-fg-muted)/40\" />\n              {title || description ? (\n                <div className=\"mt-3 w-full\">\n                  {title ? <h2 className=\"text-base font-semibold text-(--color-fg)\">{title}</h2> : null}\n                  {description ? <p className=\"mt-0.5 text-sm text-(--color-fg-muted)\">{description}</p> : null}\n                </div>\n              ) : null}\n            </div>\n            <div className=\"flex-1 overflow-y-auto px-4 pb-6\">{children}</div>\n          </motion.div>\n        </div>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n"},{"path":"components/previews/motion/bottom-sheet.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { BottomSheet } from \"@/components/motion/bottom-sheet\";\n\nexport function BottomSheetPreview() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <button\n        type=\"button\"\n        onClick={() => setOpen(true)}\n        className=\"inline-flex h-10 items-center rounded-full border border-(--color-border) bg-(--color-bg-elev) px-5 text-sm font-medium text-(--color-fg) press hover:border-(--color-border-strong)\"\n      >\n        Open bottom sheet\n      </button>\n      <BottomSheet\n        open={open}\n        onOpenChange={setOpen}\n        snapPoints={[0.4, 0.85]}\n        title=\"Quick actions\"\n        description=\"Drag the handle, fling, or swipe down to dismiss.\"\n      >\n        <ul className=\"divide-y divide-(--color-border)\">\n          {[\"Share\", \"Duplicate\", \"Move to folder\", \"Rename\", \"Archive\", \"Delete\"].map((item) => (\n            <li key={item} className=\"py-3 text-sm text-(--color-fg)\">{item}</li>\n          ))}\n        </ul>\n        <div className=\"py-12 text-center text-xs text-(--color-fg-muted)\">\n          Fling up to expand, fling down to dismiss.\n        </div>\n      </BottomSheet>\n    </>\n  );\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}