{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bottom-sheet",
  "title": "Bottom Sheet",
  "description": "A draggable, snap-point bottom sheet built on Radix Dialog for accessibility, with Motion driving the drag gesture and glide.",
  "dependencies": ["motion", "radix-ui"],
  "registryDependencies": [
    "utils",
    "@soralabs/hooks-use-controlled-state",
    "@soralabs/hooks-use-prefers-reduced-motion",
    "@soralabs/lib-get-strict-context",
    "@soralabs/lib-ease"
  ],
  "files": [
    {
      "path": "registry/primitives/radix/bottom-sheet/index.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  type PanInfo,\n  useDragControls,\n} from \"motion/react\";\nimport { Dialog as SheetPrimitive } from \"radix-ui\";\nimport {\n  type ComponentProps,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useState,\n} from \"react\";\nimport { useControlledState } from \"@/hooks/use-controlled-state\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\nimport { EASE_DRAWER } from \"@/lib/ease\";\nimport { getStrictContext } from \"@/lib/get-strict-context\";\n\n// Vaul-style glide: a long, fully-damped tween reads smoother than a spring on\n// open — no settle/overshoot, just one clean decel. Same curve drives the\n// backdrop fade so the surface and scrim move as one.\nconst DRAWER_TRANSITION = { duration: 0.5, ease: EASE_DRAWER } as const;\n\ninterface BottomSheetContextType {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n}\n\nconst [BottomSheetProvider, useBottomSheet] =\n  getStrictContext<BottomSheetContextType>(\"BottomSheetContext\");\n\ntype BottomSheetProps = ComponentProps<typeof SheetPrimitive.Root>;\n\nfunction BottomSheet({\n  open,\n  defaultOpen,\n  onOpenChange,\n  ...props\n}: BottomSheetProps) {\n  const [isOpen, setIsOpen] = useControlledState({\n    value: open,\n    defaultValue: defaultOpen ?? false,\n    onChange: onOpenChange,\n  });\n\n  return (\n    <BottomSheetProvider value={{ open: isOpen, setOpen: setIsOpen }}>\n      <SheetPrimitive.Root\n        data-slot=\"bottom-sheet\"\n        onOpenChange={setIsOpen}\n        open={isOpen}\n        {...props}\n      />\n    </BottomSheetProvider>\n  );\n}\n\ntype BottomSheetTriggerProps = ComponentProps<typeof SheetPrimitive.Trigger>;\n\nfunction BottomSheetTrigger(props: BottomSheetTriggerProps) {\n  return <SheetPrimitive.Trigger data-slot=\"bottom-sheet-trigger\" {...props} />;\n}\n\ntype BottomSheetCloseProps = ComponentProps<typeof SheetPrimitive.Close>;\n\nfunction BottomSheetClose(props: BottomSheetCloseProps) {\n  return <SheetPrimitive.Close data-slot=\"bottom-sheet-close\" {...props} />;\n}\n\ntype BottomSheetOverlayProps = HTMLMotionProps<\"div\">;\n\nfunction BottomSheetOverlay({ className, ...props }: BottomSheetOverlayProps) {\n  return (\n    <SheetPrimitive.Overlay asChild forceMount>\n      <motion.div\n        animate={{ opacity: 1 }}\n        className={cn(\n          \"fixed inset-0 z-50 bg-background/40 backdrop-blur-sm\",\n          className\n        )}\n        data-slot=\"bottom-sheet-overlay\"\n        exit={{ opacity: 0 }}\n        initial={{ opacity: 0 }}\n        transition={DRAWER_TRANSITION}\n        {...props}\n      />\n    </SheetPrimitive.Overlay>\n  );\n}\n\ninterface BottomSheetContentProps\n  extends Omit<\n    ComponentProps<typeof SheetPrimitive.Content>,\n    \"asChild\" | \"forceMount\" | \"children\" | keyof HTMLMotionProps<\"div\">\n  > {\n  children?: ReactNode;\n  className?: string;\n  defaultSnap?: number;\n  /** Min drag distance (px) past the current snap point before it dismisses. */\n  dismissThreshold?: number;\n  handleClassName?: string;\n  /** Renders the dimmed, blurred scrim behind the sheet. */\n  overlay?: boolean;\n  overlayClassName?: string;\n  /** Renders the draggable grab handle above `children`. */\n  showHandle?: boolean;\n  /** Heights (0-1 = fraction of viewport, or \"auto\"). First entry is the default. */\n  snapPoints?: (number | \"auto\")[];\n  style?: HTMLMotionProps<\"div\">[\"style\"];\n}\n\nfunction BottomSheetContent({\n  className,\n  children,\n  snapPoints = [0.5, 0.92],\n  defaultSnap = 0,\n  dismissThreshold = 120,\n  overlay = true,\n  overlayClassName,\n  showHandle = true,\n  handleClassName,\n  style,\n  ...props\n}: BottomSheetContentProps) {\n  const { open, setOpen } = useBottomSheet();\n  const [snap, setSnap] = useState(defaultSnap);\n  const dragControls = useDragControls();\n  const reduceMotion = usePrefersReducedMotion();\n\n  useEffect(() => {\n    if (open) {\n      setSnap(defaultSnap);\n    }\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) {\n      return;\n    }\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 = useCallback(\n    (_: unknown, info: PanInfo) => {\n      const velocity = info.velocity.y;\n      const offset = info.offset.y;\n\n      // Strong downward fling or large drag → dismiss (or drop one snap).\n      if (velocity > 600 || offset > dismissThreshold) {\n        const smaller = snapPoints.map((_, i) => i).filter((i) => i < snap);\n        if (\n          smaller.length &&\n          velocity < 800 &&\n          offset < dismissThreshold * 1.6\n        ) {\n          setSnap(smaller.at(-1) as number);\n        } else {\n          setOpen(false);\n        }\n        return;\n      }\n\n      // Strong upward fling → next snap.\n      if (velocity < -500) {\n        setSnap(Math.min(snapPoints.length - 1, snap + 1));\n        return;\n      }\n\n      // Otherwise snap to nearest by current offset.\n      if (offset > 80 && snap > 0) {\n        setSnap(snap - 1);\n      } else if (offset < -80 && snap < snapPoints.length - 1) {\n        setSnap(snap + 1);\n      }\n    },\n    [dismissThreshold, setOpen, snap, snapPoints]\n  );\n\n  const snapValue = snapPoints[snap];\n  const heightStyle =\n    snapValue === \"auto\"\n      ? { maxHeight: \"92vh\" }\n      : { height: `${(snapValue ?? 1) * 100}vh` };\n\n  return (\n    <AnimatePresence>\n      {open ? (\n        <SheetPrimitive.Portal forceMount>\n          {overlay ? <BottomSheetOverlay className={overlayClassName} /> : null}\n          <SheetPrimitive.Content asChild forceMount>\n            <motion.div\n              animate={reduceMotion ? { y: 0, opacity: 1 } : { y: 0 }}\n              className={cn(\n                \"fixed inset-x-4 bottom-0 z-50 mx-auto flex max-w-md flex-col overflow-hidden rounded-2xl bg-transparent pb-4 outline-none will-change-transform md:mx-auto md:w-full\",\n                className\n              )}\n              data-slot=\"bottom-sheet-content\"\n              drag=\"y\"\n              dragConstraints={{ top: 0, bottom: 0 }}\n              dragControls={dragControls}\n              dragElastic={{ top: 0.02, bottom: 0.4 }}\n              dragListener={false}\n              dragMomentum={false}\n              exit={reduceMotion ? { y: 0, opacity: 0 } : { y: \"100%\" }}\n              initial={reduceMotion ? { y: 0, opacity: 0 } : { y: \"100%\" }}\n              onDragEnd={onDragEnd}\n              style={{ ...heightStyle, ...style }}\n              transition={\n                reduceMotion\n                  ? { duration: 0.18, ease: EASE_DRAWER }\n                  : DRAWER_TRANSITION\n              }\n              {...props}\n            >\n              {showHandle ? (\n                <BottomSheetHandle\n                  className={handleClassName}\n                  dragControls={dragControls}\n                />\n              ) : null}\n              {children}\n            </motion.div>\n          </SheetPrimitive.Content>\n        </SheetPrimitive.Portal>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\ninterface BottomSheetHandleProps extends ComponentProps<\"div\"> {\n  dragControls: ReturnType<typeof useDragControls>;\n}\n\nfunction BottomSheetHandle({\n  className,\n  dragControls,\n  ...props\n}: BottomSheetHandleProps) {\n  return (\n    <div\n      className={cn(\n        \"flex shrink-0 cursor-grab touch-none flex-col items-center px-4 pt-3 pb-2 active:cursor-grabbing\",\n        className\n      )}\n      data-slot=\"bottom-sheet-handle\"\n      onPointerDown={(event) => dragControls.start(event)}\n      {...props}\n    >\n      <div className=\"h-1.5 w-10 rounded-full bg-muted-foreground/40\" />\n    </div>\n  );\n}\n\ntype BottomSheetTitleProps = ComponentProps<typeof SheetPrimitive.Title>;\n\nfunction BottomSheetTitle({ className, ...props }: BottomSheetTitleProps) {\n  return (\n    <SheetPrimitive.Title\n      className={cn(\"sr-only\", className)}\n      data-slot=\"bottom-sheet-title\"\n      {...props}\n    />\n  );\n}\n\ntype BottomSheetDescriptionProps = ComponentProps<\n  typeof SheetPrimitive.Description\n>;\n\nfunction BottomSheetDescription({\n  className,\n  ...props\n}: BottomSheetDescriptionProps) {\n  return (\n    <SheetPrimitive.Description\n      className={cn(\"sr-only\", className)}\n      data-slot=\"bottom-sheet-description\"\n      {...props}\n    />\n  );\n}\n\ntype BottomSheetPanelProps = ComponentProps<\"div\">;\n\n/** The rounded surface that sits inside `BottomSheetContent` — swap `bg-*` to restyle. */\nfunction BottomSheetPanel({ className, ...props }: BottomSheetPanelProps) {\n  return (\n    <div\n      className={cn(\n        \"relative z-[2] min-h-0 grow space-y-2 overflow-y-auto overscroll-contain rounded-2xl bg-muted p-2\",\n        className\n      )}\n      data-slot=\"bottom-sheet-panel\"\n      {...props}\n    />\n  );\n}\n\ntype BottomSheetListProps = ComponentProps<\"ul\">;\n\nfunction BottomSheetList({ className, ...props }: BottomSheetListProps) {\n  return (\n    <ul\n      className={cn(\"grid w-full space-y-1.5 text-sm\", className)}\n      data-slot=\"bottom-sheet-list\"\n      {...props}\n    />\n  );\n}\n\ninterface BottomSheetRowProps\n  extends Omit<ComponentProps<\"button\">, \"children\" | \"value\"> {\n  /** Overrides `label`/`value` entirely when you need a fully custom row layout. */\n  children?: ReactNode;\n  label: ReactNode;\n  labelClassName?: string;\n  lineClassName?: string;\n  value?: ReactNode;\n  valueClassName?: string;\n}\n\n/** One selectable row: `label ---- value`, with a hover surface and a bottom-edge reveal bar. */\nfunction BottomSheetRow({\n  className,\n  label,\n  value,\n  children,\n  labelClassName,\n  valueClassName,\n  lineClassName,\n  type = \"button\",\n  ...props\n}: BottomSheetRowProps) {\n  return (\n    <li data-slot=\"bottom-sheet-row-item\">\n      <button\n        className={cn(\n          \"relative w-full rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-accent\",\n          className\n        )}\n        data-slot=\"bottom-sheet-row\"\n        type={type}\n        {...props}\n      >\n        {children ?? (\n          <div className=\"flex flex-1 items-center justify-between gap-2 text-sm\">\n            <div\n              className={cn(\n                \"flex items-center justify-center gap-2 font-medium uppercase tracking-wide\",\n                labelClassName\n              )}\n            >\n              {label}\n            </div>\n            <span\n              className={cn(\n                \"relative h-px flex-1 rounded-2xl bg-current/20\",\n                lineClassName\n              )}\n            />\n            <span\n              className={cn(\"font-sans text-foreground/50\", valueClassName)}\n            >\n              {value}\n            </span>\n          </div>\n        )}\n      </button>\n    </li>\n  );\n}\n\nexport {\n  BottomSheet,\n  BottomSheetClose,\n  BottomSheetContent,\n  type BottomSheetContentProps,\n  BottomSheetDescription,\n  BottomSheetHandle,\n  BottomSheetList,\n  BottomSheetOverlay,\n  BottomSheetPanel,\n  type BottomSheetProps,\n  BottomSheetRow,\n  type BottomSheetRowProps,\n  BottomSheetTitle,\n  BottomSheetTrigger,\n  useBottomSheet,\n};\n",
      "type": "registry:ui",
      "target": "components/sora-ui/radix/bottom-sheet.tsx"
    }
  ],
  "meta": {
    "inspiration": {
      "type": "inspired",
      "label": "Vaul",
      "url": "https://vaul.emilkowal.ski",
      "stack": "Radix Dialog and Motion"
    }
  },
  "type": "registry:ui"
}
