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, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { EASE_DRAWER } from "@/lib/ease";
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);
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";
return () => {
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]);
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;
return createPortal(
<AnimatePresence>
{open ? (
<div className="pointer-events-none fixed inset-0 z-50">
<motion.button
type="button"
aria-label="Close bottom sheet"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={DRAWER}
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 absolute inset-0 bg-background/40 backdrop-blur-sm"
/>
<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;
}}
style={heightStyle}
className={cn(
"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",
"border border-border bg-background shadow-xl",
className,
)}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div
onPointerDown={(e) => dragControls.start(e)}
className="flex cursor-grab touch-none flex-col items-center px-4 pb-2 pt-3 active:cursor-grabbing"
>
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
{title || description ? (
<div className="mt-3 w-full">
{title ? (
<h2 className="text-base font-semibold text-foreground">
{title}
</h2>
) : null}
{description ? (
<p 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>
</div>
) : null}
</AnimatePresence>,
document.body,
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/bottom-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/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, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { EASE_DRAWER } from "@/lib/ease";
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);
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";
return () => {
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]);
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;
return createPortal(
<AnimatePresence>
{open ? (
<div className="pointer-events-none fixed inset-0 z-50">
<motion.button
type="button"
aria-label="Close bottom sheet"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={DRAWER}
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 absolute inset-0 bg-background/40 backdrop-blur-sm"
/>
<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;
}}
style={heightStyle}
className={cn(
"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",
"border border-border bg-background shadow-xl",
className,
)}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div
onPointerDown={(e) => dragControls.start(e)}
className="flex cursor-grab touch-none flex-col items-center px-4 pb-2 pt-3 active:cursor-grabbing"
>
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
{title || description ? (
<div className="mt-3 w-full">
{title ? (
<h2 className="text-base font-semibold text-foreground">
{title}
</h2>
) : null}
{description ? (
<p 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>
</div>
) : null}
</AnimatePresence>,
document.body,
);
}
API Reference
openboolean—onOpenChange(open: boolean) => void—snapPoints?{}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.
120Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated