{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bottom-sheet","type":"registry:ui","title":"Bottom Sheet","description":"Draggable glass sheet with snap points; release velocity is projected forward to pick the detent and the scrim fades with the sheet's height.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/bottom-sheet.tsx","type":"registry:ui","target":"components/ui/bottom-sheet.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useDragControls, useMotionValue, useReducedMotion, useTransform, type PanInfo } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface BottomSheetProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  /** Detents as fractions of the container height, ascending. */\n  snapPoints?: number[];\n  /** Detent index used each time the sheet opens. */\n  defaultSnap?: number;\n  /** Controlled detent index. */\n  snap?: number;\n  onSnapChange?: (index: number) => void;\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  /** Swiping below the lowest detent (or tapping the scrim) closes the sheet. */\n  dismissible?: boolean;\n  /** Position inside the nearest positioned parent instead of the viewport (no scroll lock). */\n  contained?: boolean;\n  children?: React.ReactNode;\n  className?: string;\n}\n\nconst spring = { type: \"spring\", stiffness: 380, damping: 34, mass: 0.9 } as const;\nconst DEFAULT_SNAPS = [0.25, 0.55, 0.9];\n/** How far ahead (seconds) a release velocity is projected when choosing a detent. */\nconst PROJECTION = 0.18;\n\n/**\n * Glass bottom sheet with snap points. Drag the handle or header: release velocity is projected forward to pick\n * the detent, the scrim fades with height, Escape or a fast flick past the lowest detent closes it.\n */\nexport function BottomSheet({\n  open,\n  onOpenChange,\n  snapPoints: snapPointsProp = DEFAULT_SNAPS,\n  defaultSnap = 1,\n  snap,\n  onSnapChange,\n  title,\n  description,\n  dismissible = true,\n  contained = false,\n  children,\n  className,\n}: BottomSheetProps) {\n  const reduce = useReducedMotion();\n  const uid = React.useId();\n  // Inline arrays are common; key the detents by value so effects don't restart on every render.\n  const snapKey = snapPointsProp.join(\",\");\n  const snapPoints = React.useMemo(() => snapKey.split(\",\").map(Number), [snapKey]);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const sheetRef = React.useRef<HTMLDivElement>(null);\n  const restoreRef = React.useRef<HTMLElement | null>(null);\n  const controls = useDragControls();\n  const draggedRef = React.useRef(false);\n  const onOpenChangeRef = React.useRef(onOpenChange);\n  React.useEffect(() => {\n    onOpenChangeRef.current = onOpenChange;\n  });\n\n  const [present, setPresent] = React.useState(open);\n  if (open && !present) setPresent(true);\n\n  const [innerSnap, setInnerSnap] = React.useState(defaultSnap);\n  const [wasOpen, setWasOpen] = React.useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open && snap === undefined) setInnerSnap(defaultSnap);\n  }\n  const activeSnap = Math.min(snapPoints.length - 1, Math.max(0, snap ?? innerSnap));\n\n  const [H, setH] = React.useState(0);\n  const max = snapPoints[snapPoints.length - 1];\n  const sheetH = H * max;\n  const yFor = React.useCallback((i: number) => (max - snapPoints[i]) * H, [max, snapPoints, H]);\n\n  // y is the sheet's offset from fully raised (0) down to hidden (sheetH).\n  const y = useMotionValue(10000);\n  const lowestY = yFor(0);\n  const scrim = useTransform(y, (v) => {\n    if (!sheetH) return 0;\n    if (v >= lowestY) return 0.15 * Math.max(0, 1 - (v - lowestY) / Math.max(1, sheetH - lowestY));\n    return 0.15 + 0.85 * (1 - v / Math.max(1, lowestY));\n  });\n\n  // Measure the frame (viewport or parent).\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!present || !el) return;\n    const ro = new ResizeObserver(([e]) => setH(e.contentRect.height));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [present]);\n\n  const commitSnap = React.useCallback(\n    (i: number) => {\n      if (snap === undefined) setInnerSnap(i);\n      onSnapChange?.(i);\n    },\n    [snap, onSnapChange],\n  );\n\n  // Open / close / detent animations.\n  const shownRef = React.useRef(false);\n  React.useEffect(() => {\n    if (!present || !H) return;\n    const t = reduce ? { duration: 0.2 } : spring;\n    if (open) {\n      if (!shownRef.current) {\n        y.jump(sheetH);\n        shownRef.current = true;\n      }\n      const c = animate(y, yFor(activeSnap), t);\n      return () => c.stop();\n    }\n    const c = animate(y, sheetH, reduce ? { duration: 0.15 } : { ...spring, damping: 40 });\n    c.then(() => {\n      shownRef.current = false;\n      setPresent(false);\n    });\n    return () => c.stop();\n  }, [open, present, H, sheetH, activeSnap, yFor, y, reduce]);\n\n  // Focus management, Escape and scroll lock.\n  React.useEffect(() => {\n    if (!open) return;\n    restoreRef.current = document.activeElement as HTMLElement | null;\n    const raf = requestAnimationFrame(() => sheetRef.current?.focus({ preventScroll: true }));\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") onOpenChangeRef.current(false);\n    };\n    document.addEventListener(\"keydown\", onKey);\n    const prev = document.body.style.overflow;\n    if (!contained) document.body.style.overflow = \"hidden\";\n    return () => {\n      cancelAnimationFrame(raf);\n      document.removeEventListener(\"keydown\", onKey);\n      if (!contained) document.body.style.overflow = prev;\n      restoreRef.current?.focus?.({ preventScroll: true });\n    };\n  }, [open, contained]);\n\n  function onDragEnd(_: unknown, info: PanInfo) {\n    const projected = y.get() + info.velocity.y * PROJECTION;\n    if (dismissible && (projected > sheetH - (sheetH - lowestY) * 0.5 || (y.get() > lowestY && info.velocity.y > 900))) {\n      onOpenChange(false);\n      return;\n    }\n    let best = 0;\n    snapPoints.forEach((_, i) => {\n      if (Math.abs(yFor(i) - projected) < Math.abs(yFor(best) - projected)) best = i;\n    });\n    animate(y, yFor(best), { ...spring, velocity: info.velocity.y });\n    if (best !== activeSnap) commitSnap(best);\n  }\n\n  function onHandleKey(e: React.KeyboardEvent) {\n    const n = snapPoints.length;\n    if (e.key === \"ArrowUp\") commitSnap(Math.min(n - 1, activeSnap + 1));\n    else if (e.key === \"ArrowDown\") {\n      if (activeSnap === 0 && dismissible) onOpenChange(false);\n      else commitSnap(Math.max(0, activeSnap - 1));\n    } else if (e.key === \"Enter\" || e.key === \" \") commitSnap((activeSnap + 1) % n);\n    else return;\n    e.preventDefault();\n  }\n\n  if (!present) return null;\n\n  return (\n    <div ref={rootRef} className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-[90] overflow-hidden\")}>\n      <motion.div\n        aria-hidden\n        style={{ opacity: scrim }}\n        onClick={() => dismissible && onOpenChange(false)}\n        className=\"absolute inset-0 bg-scrim\"\n      />\n      <motion.div\n        ref={sheetRef}\n        role=\"dialog\"\n        aria-modal={!contained || undefined}\n        aria-labelledby={title ? `${uid}-title` : undefined}\n        aria-describedby={description ? `${uid}-desc` : undefined}\n        tabIndex={-1}\n        drag=\"y\"\n        dragListener={false}\n        dragControls={controls}\n        dragConstraints={{ top: 0, bottom: sheetH }}\n        dragElastic={{ top: 0.06, bottom: 0.5 }}\n        dragMomentum={false}\n        onDragStart={() => {\n          draggedRef.current = true;\n        }}\n        onDragEnd={onDragEnd}\n        style={{ y, height: sheetH || undefined, visibility: H ? \"visible\" : \"hidden\" }}\n        className={cn(\n          \"absolute inset-x-0 bottom-0 mx-auto flex max-w-[640px] flex-col rounded-t-xl border border-b-0 border-border bg-glass shadow-lg outline-none backdrop-blur-xl backdrop-saturate-150\",\n          className,\n        )}\n      >\n        <div\n          onPointerDown={(e) => {\n            draggedRef.current = false;\n            controls.start(e);\n          }}\n          className=\"shrink-0 cursor-grab touch-none select-none px-5 pb-3 pt-2 active:cursor-grabbing\"\n        >\n          <button\n            type=\"button\"\n            aria-label={`Resize sheet, detent ${activeSnap + 1} of ${snapPoints.length}`}\n            onKeyDown={onHandleKey}\n            onClick={(e) => {\n              // Keyboard activation is handled in onKeyDown; a click after a drag is ignored.\n              if (e.detail === 0) return;\n              if (draggedRef.current) {\n                draggedRef.current = false;\n                return;\n              }\n              commitSnap((activeSnap + 1) % snapPoints.length);\n            }}\n            className=\"mx-auto flex h-5 w-16 items-center justify-center rounded-full outline-none focus-visible:shadow-[var(--focus-ring)]\"\n          >\n            <span className=\"h-[5px] w-10 rounded-full bg-border-strong\" />\n          </button>\n          {(title || description) && (\n            <div className=\"mt-1\">\n              {title && (\n                <h2 id={`${uid}-title`} className=\"font-display text-[19px] font-semibold leading-6 tracking-[-0.3px] text-ink\">\n                  {title}\n                </h2>\n              )}\n              {description && (\n                <p id={`${uid}-desc`} className=\"mt-0.5 text-[13px] leading-[18px] text-ink-muted\">\n                  {description}\n                </p>\n              )}\n            </div>\n          )}\n        </div>\n        <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-6\">{children}</div>\n      </motion.div>\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","bottom sheet","drawer","sheet","modal","mobile","drag","snap points","detents","glass"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/bottom-sheet"}}