{"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.dev/r/bottom-sheet/raw","detail_url":"https://beui.dev/r/bottom-sheet","raw_url":"https://beui.dev/r/bottom-sheet/raw","page_url":"https://beui.dev/components/motion/bottom-sheet","markdown_url":"https://beui.dev/components/motion/bottom-sheet.md","published_at":"2026-05-17","updated_at":"2026-08-20","dependencies":["clsx","motion","react","react-dom","tailwind-merge"],"internal":["@/components/motion/bottom-sheet","@/lib/ease","@/lib/presence-gate","@/lib/touch","@/lib/utils"],"files":[{"path":"components/motion/bottom-sheet.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/bottom-sheet\n\nimport {\n  AnimatePresence,\n  motion,\n  type PanInfo,\n  useDragControls,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type ReactNode, useEffect, useId, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_DRAWER } from \"@/lib/ease\";\nimport { PresenceGate } from \"@/lib/presence-gate\";\nimport { TOUCH_GESTURE_CONTENT_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\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 = { duration: 0.5, ease: EASE_DRAWER } as const;\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 dragControls = useDragControls();\n  const sheetRef = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const heightRef = useRef(0);\n  const uid = useId();\n  const titleId = `${uid}-title`;\n  const descriptionId = `${uid}-description`;\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\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        onOpenChange(false);\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\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, onOpenChange]);\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      return;\n    }\n\n    // Strong upward fling → next snap.\n    if (velocity < -500) {\n      setSnap((current) => Math.min(snapPoints.length - 1, current + 1));\n      return;\n    }\n\n    // Otherwise snap to nearest by current offset.\n    setSnap((current) => {\n      if (offset > 80 && current > 0) return current - 1;\n      if (offset < -80 && current < snapPoints.length - 1) return current + 1;\n      return current;\n    });\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  // Two fixed siblings, no wrapper: the scrim spans the viewport edges but\n  // carries a colour, and the sheet is pinned to the bottom, stops short of the\n  // top edge at every snap point the component ships, and paints an opaque\n  // surface either way. Both hang off `PresenceGate`, so interaction releases in\n  // the same commit that starts the exit rather than when it ends.\n  return createPortal(\n    <AnimatePresence>\n      {open ? (\n        <PresenceGate key=\"backdrop\">\n          {({ gate }) => (\n            <motion.button\n              type=\"button\"\n              aria-label=\"Close bottom sheet\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={DRAWER}\n              {...gate}\n              onClick={() => onOpenChange(false)}\n              // A dim scrim with a light blur. backdrop-blur is GPU-expensive and\n              // re-rasterizes every frame the sheet drags over it; a small radius\n              // plus more opacity keeps the glass look without the jank.\n              className=\"pointer-events-auto fixed inset-0 z-50 bg-background/40 backdrop-blur-sm\"\n            />\n          )}\n        </PresenceGate>\n      ) : null}\n      {open ? (\n        <PresenceGate key=\"sheet\">\n          {({ gate }) => (\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              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={reduce ? { duration: 0.18, ease: EASE_DRAWER } : DRAWER}\n              onAnimationComplete={() => {\n                if (sheetRef.current)\n                  heightRef.current = sheetRef.current.offsetHeight;\n              }}\n              {...gate}\n              style={{ ...heightStyle, ...gate.style }}\n              className={cn(\n                \"pointer-events-auto fixed bottom-0 left-0 right-0 z-50 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-t-3xl will-change-transform\",\n                \"border border-border bg-background shadow-xl\",\n                className,\n              )}\n              role=\"dialog\"\n              aria-modal=\"true\"\n              aria-labelledby={title ? titleId : undefined}\n              aria-describedby={description ? descriptionId : undefined}\n              aria-label={title ? undefined : \"Bottom sheet\"}\n            >\n              <div className=\"flex flex-col items-center px-4 pb-2 pt-3\">\n                {/* Drag only the pill so the title and description stay selectable. */}\n                <div\n                  onPointerDown={(event) => dragControls.start(event)}\n                  // A slow pull must not hand the gesture to iOS's callout,\n                  // which would leave the sheet frozen mid-drag.\n                  className={cn(\n                    \"flex cursor-grab touch-none items-center justify-center py-1 active:cursor-grabbing\",\n                    TOUCH_GESTURE_CONTENT_CLASS,\n                  )}\n                >\n                  <div className=\"h-1.5 w-10 rounded-full bg-muted-foreground/40\" />\n                </div>\n                {title || description ? (\n                  <div className=\"mt-2 w-full\">\n                    {title ? (\n                      <h2\n                        id={titleId}\n                        className=\"text-base font-semibold text-foreground\"\n                      >\n                        {title}\n                      </h2>\n                    ) : null}\n                    {description ? (\n                      <p\n                        id={descriptionId}\n                        className=\"mt-0.5 text-sm text-muted-foreground\"\n                      >\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          )}\n        </PresenceGate>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","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\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/presence-gate.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\n"},{"path":"lib/touch.ts","type":"util","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\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"},{"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-border bg-card px-5 text-sm font-medium text-foreground 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-border\">\n          {[\"Share\", \"Duplicate\", \"Move to folder\", \"Rename\", \"Archive\", \"Delete\"].map((item) => (\n            <li key={item} className=\"py-3 text-sm text-foreground\">{item}</li>\n          ))}\n        </ul>\n        <div className=\"py-12 text-center text-xs text-muted-foreground\">\n          Fling up to expand, fling down to dismiss.\n        </div>\n      </BottomSheet>\n    </>\n  );\n}\n"}]}