{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bottom-sheet","type":"registry:component","title":"Bottom Sheet","description":"Vaul-inspired draggable bottom sheet with snap points, inertia and glass surface.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/bottom-sheet.tsx","type":"registry:component","target":"@components/motion/bottom-sheet.tsx","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 { createPortal } from \"react-dom\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\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 [mounted, setMounted] = useState(false);\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    setMounted(true);\n  }, []);\n\n  useEffect(() => {\n    if (open) setSnap(defaultSnap);\n  }, [open, defaultSnap]);\n\n  // Lock background scroll while open. overflow:hidden alone is ignored by\n  // iOS Safari — boundary scrolls inside the sheet chain to the page, which\n  // scrolls underneath and ends up somewhere else on close. position:fixed\n  // is the lock that actually holds; restore the scroll position after.\n  useEffect(() => {\n    if (!open) return;\n    const body = document.body;\n    const scrollY = window.scrollY;\n    const prev = {\n      position: body.style.position,\n      top: body.style.top,\n      left: body.style.left,\n      right: body.style.right,\n      overflow: body.style.overflow,\n    };\n    body.style.position = \"fixed\";\n    body.style.top = `-${scrollY}px`;\n    body.style.left = \"0\";\n    body.style.right = \"0\";\n    body.style.overflow = \"hidden\";\n    return () => {\n      body.style.position = prev.position;\n      body.style.top = prev.top;\n      body.style.left = prev.left;\n      body.style.right = prev.right;\n      body.style.overflow = prev.overflow;\n      window.scrollTo(0, scrollY);\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\"\n      ? { maxHeight: \"92vh\" }\n      : { height: `${snapValue * 100}vh` };\n\n  // Portal to <body>: an ancestor with backdrop-filter or transform becomes\n  // the containing block for fixed descendants, which would position the\n  // sheet against that ancestor instead of the viewport.\n  if (!mounted) return null;\n\n  return createPortal(\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: EASE_OUT }}\n            onClick={() => onOpenChange(false)}\n            className=\"pointer-events-auto absolute inset-0 bg-background/5 backdrop-blur-md 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 ? { duration: 0.18, ease: EASE_OUT } : SPRING_PANEL\n            }\n            onAnimationComplete={() => {\n              if (sheetRef.current)\n                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-border bg-card shadow-xl\",\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-muted-foreground/40\" />\n              {title || description ? (\n                <div className=\"mt-3 w-full\">\n                  {title ? (\n                    <h2 className=\"text-base font-semibold text-foreground\">\n                      {title}\n                    </h2>\n                  ) : null}\n                  {description ? (\n                    <p className=\"mt-0.5 text-sm text-muted-foreground\">\n                      {description}\n                    </p>\n                  ) : null}\n                </div>\n              ) : null}\n            </div>\n            {/* overscroll-contain stops boundary scrolls from chaining to the page. */}\n            <div className=\"flex-1 overflow-y-auto overscroll-contain px-4 pb-6\">{children}</div>\n          </motion.div>\n        </div>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","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"}]}