"use client";
import * as React from "react";
import {
animate,
motion,
useDragControls,
useMotionValue,
useReducedMotion,
useTransform,
type PanInfo,
} from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface BottomSheetProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called when the sheet asks to open or close (drag, Escape, backdrop, close button). */
onOpenChange?: (open: boolean) => void;
/** Sheet title. Labels the dialog. */
title: React.ReactNode;
/** Optional supporting text under the title. Describes the dialog. */
description?: React.ReactNode;
/** Snap heights as fractions of the viewport height, e.g. [0.4, 0.9]. */
snapPoints?: number[];
/** Index of the snap point the sheet opens to. */
defaultSnap?: number;
/** Allow closing by dragging down and by clicking the backdrop. Escape and the close button always work. */
dismissible?: boolean;
/** Backdrop opacity once the sheet reaches its lowest snap point (0–1). */
backdropOpacity?: number;
/** Maximum sheet width in px (the sheet is centred on wide screens). */
maxWidth?: number;
/** Spring used for snapping, opening and closing. */
spring?: Spring;
/** Accent for the active snap marker and focus rings. */
accent?: string;
/** Accessible name prefix of the drag handle button. */
handleLabel?: string;
/** Accessible name of the close button. */
closeLabel?: string;
/** Classes added to the sheet panel. */
className?: string;
/** Inline styles merged onto the sheet panel. */
style?: React.CSSProperties;
children?: React.ReactNode;
}
const DEFAULT_SNAPS = [0.45, 0.9];
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
/**
* A draggable bottom sheet built on a native modal <dialog> (top layer, inert page, focus return).
* It snaps between fractional heights using drag velocity, fades its backdrop with progress,
* locks body scroll and only scrolls its content once it reaches the top snap point.
*/
export function BottomSheet({
open,
defaultOpen = false,
onOpenChange,
title,
description,
snapPoints = DEFAULT_SNAPS,
defaultSnap = 0,
dismissible = true,
backdropOpacity = 0.5,
maxWidth = 640,
spring = { stiffness: 420, damping: 42, mass: 1 },
accent = "#FF7448",
handleLabel = "Resize sheet",
closeLabel = "Close",
className = "",
style,
children,
}: BottomSheetProps) {
const uid = React.useId();
const titleId = `${uid}-title`;
const descriptionId = `${uid}-description`;
const reduceMotion = useReducedMotion();
const dialogRef = React.useRef<HTMLDialogElement>(null);
const returnFocusRef = React.useRef<HTMLElement | null>(null);
const releaseVelocityRef = React.useRef(0);
const draggedRef = React.useRef(false);
const dragControls = useDragControls();
// Normalised snap points: unique, ascending, within (0, 1].
const snaps = Array.from(new Set(snapPoints.map((s) => clamp(s, 0.1, 1)))).sort((a, b) => a - b);
if (!snaps.length) snaps.push(0.5);
const top = snaps[snaps.length - 1];
const initialSnap = clamp(Math.round(defaultSnap), 0, snaps.length - 1);
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isOpen = open ?? internalOpen;
const [snapIndex, setSnapIndex] = React.useState(initialSnap);
const activeSnap = Math.min(snapIndex, snaps.length - 1);
const atTop = activeSnap === snaps.length - 1;
// Each time the sheet opens it starts from the default snap point.
const [wasOpen, setWasOpen] = React.useState(isOpen);
if (wasOpen !== isOpen) {
setWasOpen(isOpen);
if (isOpen) setSnapIndex(initialSnap);
}
const setOpen = (next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
};
/* ------------------------------ motion values ------------------------------ */
// y = how far the sheet is pushed down from its top snap position, in px.
const y = useMotionValue(0);
const viewport = useMotionValue(0);
// 0 when fully closed, 1 at (or above) the lowest snap point.
const progress = useTransform([y, viewport], ([yv, h]: number[]) => {
const closedY = top * h;
const lowestY = (top - snaps[0]) * h;
const range = closedY - lowestY;
return range > 0 ? clamp((closedY - yv) / range, 0, 1) : 1;
});
const backdrop = useTransform(progress, (p) => p * backdropOpacity);
const transition = (velocity = 0) =>
reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring, velocity };
const snapY = (index: number, h: number) => (top - snaps[index]) * h;
const goTo = (index: number, velocity = 0) => {
setSnapIndex(index);
animate(y, snapY(index, viewport.get()), transition(velocity));
};
/* --------------------------------- effects --------------------------------- */
const animateOpen = React.useEffectEvent((dialog: HTMLDialogElement) => {
const h = dialog.clientHeight || window.innerHeight;
viewport.set(h);
y.set(top * h); // start fully below the fold
return animate(y, snapY(activeSnap, h), transition());
});
const animateToSnap = React.useEffectEvent(() => animate(y, snapY(activeSnap, viewport.get()), transition()));
const animateClose = React.useEffectEvent(() => {
const velocity = releaseVelocityRef.current;
releaseVelocityRef.current = 0;
return animate(y, top * viewport.get(), transition(velocity));
});
// Drive the native dialog from state: showModal → slide in; close → slide out, then close().
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
// Re-opened while the exit animation was still running: just slide back up.
if (dialog.open) {
const controls = animateToSnap();
return () => controls.stop();
}
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialog.showModal();
const controls = animateOpen(dialog);
return () => controls.stop();
}
if (dialog.open) {
let cancelled = false;
const controls = animateClose();
controls.then(() => {
if (cancelled) return;
dialog.close();
returnFocusRef.current?.focus({ preventScroll: true });
returnFocusRef.current = null;
});
return () => {
cancelled = true;
controls.stop();
};
}
}, [isOpen]);
// Keep snap positions in px correct when the viewport resizes.
const handleResize = React.useEffectEvent(() => {
const dialog = dialogRef.current;
if (!dialog?.open) return;
const h = dialog.clientHeight || window.innerHeight;
viewport.set(h);
if (isOpen) y.set(snapY(activeSnap, h));
});
React.useEffect(() => {
if (!isOpen) return;
const onResize = () => handleResize();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [isOpen]);
// Body scroll lock while open, compensating for the scrollbar so the page does not shift.
React.useEffect(() => {
if (!isOpen) return;
const html = document.documentElement;
const body = document.body;
const scrollbar = window.innerWidth - html.clientWidth;
const prev = { overflow: html.style.overflow, paddingRight: body.style.paddingRight };
html.style.overflow = "hidden";
if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`;
return () => {
html.style.overflow = prev.overflow;
body.style.paddingRight = prev.paddingRight;
};
}, [isOpen]);
/* ---------------------------------- drag ---------------------------------- */
const startDrag = (event: React.PointerEvent) => {
if (event.button !== 0) return;
draggedRef.current = false;
dragControls.start(event);
};
const handleDragEnd = (_: PointerEvent, info: PanInfo) => {
const h = viewport.get();
const current = y.get();
// Project where a flick would carry the sheet, then pick the closest resting place.
const projected = current + info.velocity.y * 0.2;
const lowestY = snapY(0, h);
const closedY = top * h;
if (dismissible && (projected > lowestY + (closedY - lowestY) * 0.5 || (info.velocity.y > 1100 && current > lowestY - 24))) {
releaseVelocityRef.current = info.velocity.y;
setOpen(false);
return;
}
let best = 0;
snaps.forEach((_, i) => {
if (Math.abs(snapY(i, h) - projected) < Math.abs(snapY(best, h) - projected)) best = i;
});
goTo(best, info.velocity.y);
};
/* -------------------------------- keyboard -------------------------------- */
const handleHandleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowUp" && activeSnap < snaps.length - 1) {
event.preventDefault();
goTo(activeSnap + 1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
if (activeSnap > 0) goTo(activeSnap - 1);
else if (dismissible) setOpen(false);
}
};
const percent = Math.round(snaps[activeSnap] * 100);
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onCancel={(event) => {
// Escape: keep React state the source of truth so the exit animation can run.
event.preventDefault();
setOpen(false);
}}
onClose={() => {
// Closed by something other than our state (e.g. a form with method="dialog").
if (isOpen) setOpen(false);
}}
className="m-0 h-full max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 backdrop:bg-transparent"
style={{ ["--bs-accent" as string]: accent }}
>
<motion.div
aria-hidden="true"
className="absolute inset-0 bg-zinc-950"
style={{ opacity: backdrop }}
onClick={() => {
if (dismissible) setOpen(false);
}}
/>
<motion.div
drag="y"
dragControls={dragControls}
dragListener={false}
dragMomentum={false}
dragConstraints={{ top: 0 }}
dragElastic={{ top: 0.06, bottom: 1 }}
onDragStart={() => {
draggedRef.current = true;
}}
onDragEnd={handleDragEnd}
className={`absolute inset-x-0 bottom-0 mx-auto flex w-full flex-col rounded-t-[22px] border border-b-0 border-zinc-900/10 bg-white text-zinc-900 shadow-[0_-20px_60px_-30px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_-20px_60px_-30px_rgba(0,0,0,0.9)] ${className}`}
style={{ y, height: `${top * 100}%`, maxWidth, ...style }}
>
{/* Covers the gap below the sheet when a spring or drag overshoots upward. */}
<div aria-hidden="true" className="absolute inset-x-[-1px] top-full h-24 border-x border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900" />
<div className="shrink-0 cursor-grab touch-none select-none px-5 pb-3 active:cursor-grabbing" onPointerDown={startDrag}>
<div className="flex justify-center pt-2">
<button
type="button"
aria-label={`${handleLabel}: ${percent}% height`}
aria-keyshortcuts="ArrowUp ArrowDown"
onClick={() => {
// A drag that ends on the handle also fires click; ignore it.
if (draggedRef.current) {
draggedRef.current = false;
return;
}
goTo((activeSnap + 1) % snaps.length);
}}
onKeyDown={handleHandleKeyDown}
className="group flex h-6 w-16 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--bs-accent)]"
>
<span className="h-1 w-10 rounded-full bg-zinc-900/15 transition-colors group-hover:bg-zinc-900/30 dark:bg-white/20 dark:group-hover:bg-white/35" />
</button>
</div>
<div className="mt-2 flex items-start gap-4">
<div className="min-w-0 flex-1">
<h2 id={titleId} className="text-base font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">
{title}
</h2>
{description && (
<p id={descriptionId} className="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">
{description}
</p>
)}
</div>
{snaps.length > 1 && (
<div aria-hidden="true" className="flex items-center gap-1.5 pt-1.5">
{snaps.map((s, i) => (
<span
key={s}
className={`h-1.5 rounded-full transition-[width,background-color] duration-300 ${
i === activeSnap ? "w-4 bg-[var(--bs-accent)]" : "w-1.5 bg-zinc-900/15 dark:bg-white/20"
}`}
/>
))}
<span className="ml-1 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-500 dark:text-zinc-400">
{percent}%
</span>
</div>
)}
<button
type="button"
aria-label={closeLabel}
onClick={() => setOpen(false)}
className="-mr-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-zinc-500 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--bs-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white"
>
<svg viewBox="0 0 16 16" className="h-4 w-4" aria-hidden="true">
<path d="m4 4 8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
{/*
Below the top snap the content is a drag surface (no scrolling); at the top snap it
scrolls on its own and the header remains the drag handle.
*/}
<div
onPointerDown={atTop ? undefined : startDrag}
className={`min-h-0 flex-1 overscroll-contain border-t border-zinc-900/[0.08] px-5 pb-8 pt-4 dark:border-white/[0.08] ${
atTop ? "touch-pan-y overflow-y-auto" : "touch-none overflow-hidden"
}`}
>
{children}
</div>
</motion.div>
</dialog>
);
}