Bottom Sheet
Vaul-inspired draggable bottom sheet with snap points, inertia and glass surface.
Preview
TSXcomponents/previews/motion/bottom-sheet.preview.tsx
"use client";
import { useState } from "react";
import { BottomSheet } from "@/components/motion/bottom-sheet";
export function BottomSheetPreview() {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
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)"
>
Open bottom sheet
</button>
<BottomSheet
open={open}
onOpenChange={setOpen}
snapPoints={[0.4, 0.85]}
title="Quick actions"
description="Drag the handle, fling, or swipe down to dismiss."
>
<ul className="divide-y divide-border">
{["Share", "Duplicate", "Move to folder", "Rename", "Archive", "Delete"].map((item) => (
<li key={item} className="py-3 text-sm text-foreground">{item}</li>
))}
</ul>
<div className="py-12 text-center text-xs text-muted-foreground">
Fling up to expand, fling down to dismiss.
</div>
</BottomSheet>
</>
);
}
TSXcomponents/motion/bottom-sheet.tsx
"use client";
// beui.dev/components/motion/bottom-sheet
import {
AnimatePresence,
motion,
type PanInfo,
useDragControls,
useReducedMotion,
} from "motion/react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { EASE_DRAWER } from "@/lib/ease";
import { PresenceGate } from "@/lib/presence-gate";
import { TOUCH_GESTURE_CONTENT_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Vaul-style glide: a long, fully-damped tween reads smoother than a spring on
// open — no settle/overshoot, just one clean decel. Same curve drives the
// backdrop fade so the surface and scrim move as one.
const DRAWER = { duration: 0.5, ease: EASE_DRAWER } as const;
export interface BottomSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Heights (0-1 = fraction of viewport, or "auto"). First entry is default. */
snapPoints?: (number | "auto")[];
defaultSnap?: number;
title?: string;
description?: string;
children?: ReactNode;
className?: string;
/** Min drag distance (px) past current snap to dismiss. */
dismissThreshold?: number;
}
export function BottomSheet({
open,
onOpenChange,
snapPoints = [0.5, 0.92],
defaultSnap = 0,
title,
description,
children,
className,
dismissThreshold = 120,
}: BottomSheetProps) {
const [snap, setSnap] = useState(defaultSnap);
const [mounted, setMounted] = useState(false);
const dragControls = useDragControls();
const sheetRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const heightRef = useRef(0);
const uid = useId();
const titleId = `${uid}-title`;
const descriptionId = `${uid}-description`;
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (open) setSnap(defaultSnap);
}, [open, defaultSnap]);
// Lock background scroll while open. overflow:hidden alone is ignored by
// iOS Safari — boundary scrolls inside the sheet chain to the page, which
// scrolls underneath and ends up somewhere else on close. position:fixed
// is the lock that actually holds; restore the scroll position after.
useEffect(() => {
if (!open) return;
const body = document.body;
const scrollY = window.scrollY;
const prev = {
position: body.style.position,
top: body.style.top,
left: body.style.left,
right: body.style.right,
overflow: body.style.overflow,
};
body.style.position = "fixed";
body.style.top = `-${scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.overflow = "hidden";
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onOpenChange(false);
}
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
body.style.position = prev.position;
body.style.top = prev.top;
body.style.left = prev.left;
body.style.right = prev.right;
body.style.overflow = prev.overflow;
window.scrollTo(0, scrollY);
};
}, [open, onOpenChange]);
const onDragEnd = (_: unknown, info: PanInfo) => {
const velocity = info.velocity.y;
const offset = info.offset.y;
// Strong downward fling or large drag → dismiss.
if (velocity > 600 || offset > dismissThreshold) {
const smaller = snapPoints.map((_, i) => i).filter((i) => i < snap);
if (smaller.length && velocity < 800 && offset < dismissThreshold * 1.6) {
setSnap(smaller[smaller.length - 1]);
} else {
onOpenChange(false);
}
return;
}
// Strong upward fling → next snap.
if (velocity < -500) {
setSnap((current) => Math.min(snapPoints.length - 1, current + 1));
return;
}
// Otherwise snap to nearest by current offset.
setSnap((current) => {
if (offset > 80 && current > 0) return current - 1;
if (offset < -80 && current < snapPoints.length - 1) return current + 1;
return current;
});
};
const snapValue = snapPoints[snap];
const heightStyle =
snapValue === "auto"
? { maxHeight: "92vh" }
: { height: `${snapValue * 100}vh` };
// Portal to <body>: an ancestor with backdrop-filter or transform becomes
// the containing block for fixed descendants, which would position the
// sheet against that ancestor instead of the viewport.
if (!mounted) return null;
// Two fixed siblings, no wrapper: the scrim spans the viewport edges but
// carries a colour, and the sheet is pinned to the bottom, stops short of the
// top edge at every snap point the component ships, and paints an opaque
// surface either way. Both hang off `PresenceGate`, so interaction releases in
// the same commit that starts the exit rather than when it ends. See
// tests/fixed-overlay-edge-sampling.test.tsx.
return createPortal(
<AnimatePresence>
{open ? (
<PresenceGate key="backdrop">
{({ gate }) => (
<motion.button
type="button"
aria-label="Close bottom sheet"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={DRAWER}
{...gate}
onClick={() => onOpenChange(false)}
// A dim scrim with a light blur. backdrop-blur is GPU-expensive and
// re-rasterizes every frame the sheet drags over it; a small radius
// plus more opacity keeps the glass look without the jank.
className="pointer-events-auto fixed inset-0 z-50 bg-background/40 backdrop-blur-sm"
/>
)}
</PresenceGate>
) : null}
{open ? (
<PresenceGate key="sheet">
{({ gate }) => (
<motion.div
ref={sheetRef}
drag="y"
dragControls={dragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0.02, bottom: 0.4 }}
dragMomentum={false}
onDragEnd={onDragEnd}
initial={reduce ? { y: 0, opacity: 0 } : { y: "100%" }}
animate={reduce ? { y: 0, opacity: 1 } : { y: 0 }}
exit={reduce ? { y: 0, opacity: 0 } : { y: "100%" }}
transition={reduce ? { duration: 0.18, ease: EASE_DRAWER } : DRAWER}
onAnimationComplete={() => {
if (sheetRef.current)
heightRef.current = sheetRef.current.offsetHeight;
}}
{...gate}
style={{ ...heightStyle, ...gate.style }}
className={cn(
"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",
"border border-border bg-background shadow-xl",
className,
)}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descriptionId : undefined}
aria-label={title ? undefined : "Bottom sheet"}
>
<div className="flex flex-col items-center px-4 pb-2 pt-3">
{/* Drag only the pill so the title and description stay selectable. */}
<div
onPointerDown={(event) => dragControls.start(event)}
// A slow pull must not hand the gesture to iOS's callout,
// which would leave the sheet frozen mid-drag.
className={cn(
"flex cursor-grab touch-none items-center justify-center py-1 active:cursor-grabbing",
TOUCH_GESTURE_CONTENT_CLASS,
)}
>
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
</div>
{title || description ? (
<div className="mt-2 w-full">
{title ? (
<h2
id={titleId}
className="text-base font-semibold text-foreground"
>
{title}
</h2>
) : null}
{description ? (
<p
id={descriptionId}
className="mt-0.5 text-sm text-muted-foreground"
>
{description}
</p>
) : null}
</div>
) : null}
</div>
{/* overscroll-contain stops boundary scrolls from chaining to the page. */}
<div className="flex-1 overflow-y-auto overscroll-contain px-4 pb-6">{children}</div>
</motion.div>
)}
</PresenceGate>
) : null}
</AnimatePresence>,
document.body,
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/bottom-sheetbottom-sheetbottom-sheet
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/presence-gate.tsx
"use client";
import { useIsPresent } from "motion/react";
import type { ReactNode } from "react";
export interface PresenceGateRenderProps {
/**
* False from the render that starts the exit animation onward. An overlay
* kept in the tree by `AnimatePresence` is still the topmost thing on the
* page, so anything it decides from `open` alone stays true for the whole
* exit — this is the boolean that already knows the overlay is leaving.
*/
isPresent: boolean;
/**
* Spread onto every layer that takes pointer events while the overlay is
* open. Interaction releases in the same commit that starts the exit while
* the visual exit keeps playing: pointer events stop landing, and `inert`
* drops the subtree from focus order, from tab order and from the
* accessibility tree — an exiting dialog is not a dialog you can still type
* into. A layer that never takes pointer events (a wrapper that only centres
* the panel) takes `inert={!isPresent}` alone, so its own
* `pointer-events-none` is not overwritten.
*/
gate: {
inert: boolean;
style: { pointerEvents: "auto" | "none" };
};
}
export interface PresenceGateProps {
children: (props: PresenceGateRenderProps) => ReactNode;
}
/**
* Reads the presence of the subtree it renders and hands it down.
*
* `useIsPresent` only answers inside the `AnimatePresence` subtree, and the
* components that own an overlay render the `AnimatePresence` themselves, so
* the boolean has to be read one component further down: this is that
* component, and the render prop is how it reaches the layers.
*/
export function PresenceGate({ children }: PresenceGateProps) {
const isPresent = useIsPresent();
return children({
isPresent,
gate: {
inert: !isPresent,
style: { pointerEvents: isPresent ? "auto" : "none" },
},
});
}
TSXlib/touch.ts
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/bottom-sheet.tsx
"use client";
// beui.dev/components/motion/bottom-sheet
import {
AnimatePresence,
motion,
type PanInfo,
useDragControls,
useReducedMotion,
} from "motion/react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { EASE_DRAWER } from "@/lib/ease";
import { PresenceGate } from "@/lib/presence-gate";
import { TOUCH_GESTURE_CONTENT_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Vaul-style glide: a long, fully-damped tween reads smoother than a spring on
// open — no settle/overshoot, just one clean decel. Same curve drives the
// backdrop fade so the surface and scrim move as one.
const DRAWER = { duration: 0.5, ease: EASE_DRAWER } as const;
export interface BottomSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Heights (0-1 = fraction of viewport, or "auto"). First entry is default. */
snapPoints?: (number | "auto")[];
defaultSnap?: number;
title?: string;
description?: string;
children?: ReactNode;
className?: string;
/** Min drag distance (px) past current snap to dismiss. */
dismissThreshold?: number;
}
export function BottomSheet({
open,
onOpenChange,
snapPoints = [0.5, 0.92],
defaultSnap = 0,
title,
description,
children,
className,
dismissThreshold = 120,
}: BottomSheetProps) {
const [snap, setSnap] = useState(defaultSnap);
const [mounted, setMounted] = useState(false);
const dragControls = useDragControls();
const sheetRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const heightRef = useRef(0);
const uid = useId();
const titleId = `${uid}-title`;
const descriptionId = `${uid}-description`;
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (open) setSnap(defaultSnap);
}, [open, defaultSnap]);
// Lock background scroll while open. overflow:hidden alone is ignored by
// iOS Safari — boundary scrolls inside the sheet chain to the page, which
// scrolls underneath and ends up somewhere else on close. position:fixed
// is the lock that actually holds; restore the scroll position after.
useEffect(() => {
if (!open) return;
const body = document.body;
const scrollY = window.scrollY;
const prev = {
position: body.style.position,
top: body.style.top,
left: body.style.left,
right: body.style.right,
overflow: body.style.overflow,
};
body.style.position = "fixed";
body.style.top = `-${scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.overflow = "hidden";
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onOpenChange(false);
}
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
body.style.position = prev.position;
body.style.top = prev.top;
body.style.left = prev.left;
body.style.right = prev.right;
body.style.overflow = prev.overflow;
window.scrollTo(0, scrollY);
};
}, [open, onOpenChange]);
const onDragEnd = (_: unknown, info: PanInfo) => {
const velocity = info.velocity.y;
const offset = info.offset.y;
// Strong downward fling or large drag → dismiss.
if (velocity > 600 || offset > dismissThreshold) {
const smaller = snapPoints.map((_, i) => i).filter((i) => i < snap);
if (smaller.length && velocity < 800 && offset < dismissThreshold * 1.6) {
setSnap(smaller[smaller.length - 1]);
} else {
onOpenChange(false);
}
return;
}
// Strong upward fling → next snap.
if (velocity < -500) {
setSnap((current) => Math.min(snapPoints.length - 1, current + 1));
return;
}
// Otherwise snap to nearest by current offset.
setSnap((current) => {
if (offset > 80 && current > 0) return current - 1;
if (offset < -80 && current < snapPoints.length - 1) return current + 1;
return current;
});
};
const snapValue = snapPoints[snap];
const heightStyle =
snapValue === "auto"
? { maxHeight: "92vh" }
: { height: `${snapValue * 100}vh` };
// Portal to <body>: an ancestor with backdrop-filter or transform becomes
// the containing block for fixed descendants, which would position the
// sheet against that ancestor instead of the viewport.
if (!mounted) return null;
// Two fixed siblings, no wrapper: the scrim spans the viewport edges but
// carries a colour, and the sheet is pinned to the bottom, stops short of the
// top edge at every snap point the component ships, and paints an opaque
// surface either way. Both hang off `PresenceGate`, so interaction releases in
// the same commit that starts the exit rather than when it ends. See
// tests/fixed-overlay-edge-sampling.test.tsx.
return createPortal(
<AnimatePresence>
{open ? (
<PresenceGate key="backdrop">
{({ gate }) => (
<motion.button
type="button"
aria-label="Close bottom sheet"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={DRAWER}
{...gate}
onClick={() => onOpenChange(false)}
// A dim scrim with a light blur. backdrop-blur is GPU-expensive and
// re-rasterizes every frame the sheet drags over it; a small radius
// plus more opacity keeps the glass look without the jank.
className="pointer-events-auto fixed inset-0 z-50 bg-background/40 backdrop-blur-sm"
/>
)}
</PresenceGate>
) : null}
{open ? (
<PresenceGate key="sheet">
{({ gate }) => (
<motion.div
ref={sheetRef}
drag="y"
dragControls={dragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0.02, bottom: 0.4 }}
dragMomentum={false}
onDragEnd={onDragEnd}
initial={reduce ? { y: 0, opacity: 0 } : { y: "100%" }}
animate={reduce ? { y: 0, opacity: 1 } : { y: 0 }}
exit={reduce ? { y: 0, opacity: 0 } : { y: "100%" }}
transition={reduce ? { duration: 0.18, ease: EASE_DRAWER } : DRAWER}
onAnimationComplete={() => {
if (sheetRef.current)
heightRef.current = sheetRef.current.offsetHeight;
}}
{...gate}
style={{ ...heightStyle, ...gate.style }}
className={cn(
"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",
"border border-border bg-background shadow-xl",
className,
)}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descriptionId : undefined}
aria-label={title ? undefined : "Bottom sheet"}
>
<div className="flex flex-col items-center px-4 pb-2 pt-3">
{/* Drag only the pill so the title and description stay selectable. */}
<div
onPointerDown={(event) => dragControls.start(event)}
// A slow pull must not hand the gesture to iOS's callout,
// which would leave the sheet frozen mid-drag.
className={cn(
"flex cursor-grab touch-none items-center justify-center py-1 active:cursor-grabbing",
TOUCH_GESTURE_CONTENT_CLASS,
)}
>
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
</div>
{title || description ? (
<div className="mt-2 w-full">
{title ? (
<h2
id={titleId}
className="text-base font-semibold text-foreground"
>
{title}
</h2>
) : null}
{description ? (
<p
id={descriptionId}
className="mt-0.5 text-sm text-muted-foreground"
>
{description}
</p>
) : null}
</div>
) : null}
</div>
{/* overscroll-contain stops boundary scrolls from chaining to the page. */}
<div className="flex-1 overflow-y-auto overscroll-contain px-4 pb-6">{children}</div>
</motion.div>
)}
</PresenceGate>
) : null}
</AnimatePresence>,
document.body,
);
}
API Reference
openboolean—onOpenChange(open: boolean) => void—snapPoints?(number | "auto")[]Heights (0-1 = fraction of viewport, or "auto"). First entry is default.
[0.5, 0.92]defaultSnap?number0title?string—description?string—className?string—dismissThreshold?numberMin drag distance (px) past current snap to dismiss.
120Related components
Tooltip
Hover or focus tooltip with blur enter/exit and spring spawn.
Popover
Gooey popover whose panel oozes out of the trigger through an SVG goo filter — a liquid neck that stretches and pinches — with crisp content fading in on top, plus a Morph variant that clip-morphs open from the trigger corner. Click or hover trigger, controlled or uncontrolled.
Morphing Modal
Family-app-style modal. A single panel that morphs its height as you navigate between inner views, with blur cross-fade on content.
Updated