Drag Sheet
OverlaysA bottom sheet with snap points that feels native rather than like a `<div>` with a transition — three formulas and a state machine. Snapping is velocity-projected, so a flick and a slow drag past the same point resolve differently; bounds use Apple's rubber-band curve; and the drag-vs-scroll decision is made on the first `pointermove` past a 4px threshold and locked for the whole gesture, which is what stops a sheet from stealing a scroll halfway through.
Three detents
Flick the handle hard and it carries past a detent. Drag it slowly past the same point and it settles on the nearer one — the position is identical, only the velocity differs.
01
Scrollable row 1
Drag down from here while scrolled — the sheet stays put
02
Scrollable row 2
Drag down from here while scrolled — the sheet stays put
03
Scrollable row 3
Drag down from here while scrolled — the sheet stays put
04
Scrollable row 4
Drag down from here while scrolled — the sheet stays put
05
Scrollable row 5
Drag down from here while scrolled — the sheet stays put
06
Scrollable row 6
Drag down from here while scrolled — the sheet stays put
07
Scrollable row 7
Drag down from here while scrolled — the sheet stays put
08
Scrollable row 8
Drag down from here while scrolled — the sheet stays put
09
Scrollable row 9
Drag down from here while scrolled — the sheet stays put
10
Scrollable row 10
Drag down from here while scrolled — the sheet stays put
11
Scrollable row 11
Drag down from here while scrolled — the sheet stays put
12
Scrollable row 12
Drag down from here while scrolled — the sheet stays put
13
Scrollable row 13
Drag down from here while scrolled — the sheet stays put
14
Scrollable row 14
Drag down from here while scrolled — the sheet stays put
15
Scrollable row 15
Drag down from here while scrolled — the sheet stays put
16
Scrollable row 16
Drag down from here while scrolled — the sheet stays put
17
Scrollable row 17
Drag down from here while scrolled — the sheet stays put
18
Scrollable row 18
Drag down from here while scrolled — the sheet stays put
- Arbitration: scroll the list down, then drag down from inside it — the sheet does not move. Scroll back to the top and the same gesture drags the sheet.
- Locked for the gesture: start a downward drag mid-list and keep going past the top. The sheet still doesn't grab it — the decision was made on the first move.
- Rubber band: pull the handle up past the tallest detent.
components/ui/drag-sheet.tsx
"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
/**
* DragSheet — a bottom sheet with snap points that behaves like a native one.
*
* "Native feel" here is not vibes. It is three formulas and a state machine,
* and all four are in this file.
*
* ## 1 · Velocity-projected snapping
*
* Do **not** snap to the nearest detent. Snap to the detent nearest to
* `position + velocity × decay` (decay ≈ 0.35s). This is the single change
* that separates a sheet that feels alive from one that feels like a `<div>`:
* a fast flick past the midpoint carries through to the next detent, while a
* slow drag past the same point falls back. Same position, different outcome,
* because intent lives in the velocity.
*
* Velocity is sampled over the **last ~100ms**, not the whole gesture. Average
* a two-second drag and a flick at the very end reads as barely moving.
*
* ## 2 · Rubber-band resistance
*
* Past the bounds, Apple's curve: `(1 − 1/(x·c/d + 1))·d` with `c ≈ 0.55`.
* It is asymptotic — displacement approaches `d` but never reaches it — so the
* sheet never runs away, and resistance grows the further you pull.
*
* ## 3 · Drag-vs-scroll arbitration — the actual difficulty
*
* The sheet contains scrollable content, so every gesture is ambiguous: is
* this dragging the sheet or scrolling its contents? The rule:
*
* - from the handle → always the sheet;
* - dragging **down** while the content is at `scrollTop === 0` → the sheet;
* - dragging **up** while the sheet is below its tallest detent → the sheet
* (it grows before its contents scroll, as iOS does);
* - anything else → let the content scroll, and never move the sheet.
*
* The part that matters is not the rule, it is *when* it is evaluated. The
* decision is made on the **first `pointermove` that clears a 4px threshold**
* and then **locked for the rest of the gesture**. Re-evaluating mid-gesture is
* precisely what makes every hand-rolled sheet feel broken: you drag down, the
* content hits `scrollTop === 0` halfway through, and the sheet suddenly grabs
* a gesture that started as a scroll.
*
* The 4px threshold is part of it — deciding on the very first pixel means a
* jitter at touch-down picks the axis, and it picks it wrong about half the
* time.
*
* ## Why `touch-action` is set the way it is
*
* `touch-action: none` on the **handle only**. Put it on the sheet and native
* content scrolling dies. The content keeps `pan-y` plus
* `overscroll-behavior: contain`, which is what makes the arbitration possible
* at all: at `scrollTop === 0` a downward pan has nowhere to scroll, so the
* browser doesn't claim the gesture and our `pointermove` handler is free to
* drive the sheet.
*
* ## Structure
*
* The sheet stays mounted and is `inert` + `aria-hidden` when closed, rather
* than unmounting. That makes open/close a single continuous animation on one
* motion value with no exit-animation bookkeeping, and `inert` keeps the closed
* content out of the accessibility tree and off the tab order properly.
*/
// ---------------------------------------------------------------------------
// The three formulas — pure, no DOM.
// ---------------------------------------------------------------------------
/**
* Apple's rubber-band curve. `overshoot` is signed; the result carries the
* same sign. Asymptotically approaches `dimension`, so resistance rises
* without bound but displacement does not.
*/
export function rubberBand(
overshoot: number,
dimension: number,
c = 0.55,
): number {
if (dimension <= 0 || overshoot === 0) return 0;
const x = Math.abs(overshoot);
const displaced = (1 - 1 / ((x * c) / dimension + 1)) * dimension;
return overshoot < 0 ? -displaced : displaced;
}
/** Free travel inside `[min, max]`, rubber-banded outside it. */
export function applyBounds(
y: number,
min: number,
max: number,
dimension: number,
c = 0.55,
): number {
if (y < min) return min + rubberBand(y - min, dimension, c);
if (y > max) return max + rubberBand(y - max, dimension, c);
return y;
}
/**
* The snap target, chosen by where the sheet is *heading* rather than where it
* currently is. `decay` is how far into the future to look, in seconds.
*/
export function projectedSnap(
y: number,
velocity: number,
snaps: number[],
decay = 0.35,
): number {
const projected = y + velocity * decay;
let best = snaps[0];
let bestDistance = Infinity;
for (const s of snaps) {
const d = Math.abs(s - projected);
if (d < bestDistance) {
bestDistance = d;
best = s;
}
}
return best;
}
/** Velocity in px/s from the tail of a sample buffer. */
export function velocityFrom(
samples: { t: number; y: number }[],
windowMs = 100,
): number {
if (samples.length < 2) return 0;
const last = samples[samples.length - 1];
let first = samples[0];
for (const s of samples) {
if (last.t - s.t <= windowMs) {
first = s;
break;
}
}
const dt = (last.t - first.t) / 1000;
if (dt <= 0) return 0;
return (last.y - first.y) / dt;
}
/** Resolve snap points: values ≤ 1 are fractions of `containerHeight`. */
export function resolveHeights(
snapPoints: number[],
containerHeight: number,
): number[] {
return snapPoints
.map((p) => (p <= 1 ? p * containerHeight : p))
.filter((h) => h > 0)
.sort((a, b) => a - b);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const DECIDE_THRESHOLD = 4;
const VELOCITY_WINDOW = 100;
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
type GestureMode = "sheet" | "scroll" | "none";
export interface DragSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Detent heights, ascending. Values ≤ 1 are fractions of the container's
* height; larger values are pixels.
*/
snapPoints?: number[];
/** Detent index used the first time the sheet opens. */
initialSnap?: number;
/** Allow dragging down past the smallest detent to dismiss. */
dismissible?: boolean;
/** How far ahead the velocity projection looks, in seconds. */
decay?: number;
/** Rubber-band constant. Lower = stiffer. */
resistance?: number;
/** Accessible title for the dialog. */
title: string;
children?: React.ReactNode;
/** `fixed` covers the viewport; `absolute` scopes it to a positioned parent. */
position?: "fixed" | "absolute";
className?: string;
}
export function DragSheet({
open,
onOpenChange,
snapPoints = [0.45, 0.92],
initialSnap = 0,
dismissible = true,
decay = 0.35,
resistance = 0.55,
title,
children,
position = "fixed",
className,
}: DragSheetProps) {
const overlayRef = React.useRef<HTMLDivElement>(null);
const sheetRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const restoreRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
const y = useMotionValue(0);
const [metrics, setMetrics] = React.useState({
heights: [] as number[],
sheetHeight: 0,
});
const [snapIndex, setSnapIndex] = React.useState(initialSnap);
const reduce =
typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches;
// Live gesture state — refs, because none of it should re-render anything.
const gesture = React.useRef<{
active: boolean;
mode: GestureMode;
decided: boolean;
fromHandle: boolean;
startX: number;
startY: number;
startSheetY: number;
pointerId: number;
samples: { t: number; y: number }[];
} | null>(null);
// --- metrics ------------------------------------------------------------
// Keyed on the values, not the array identity: an inline `snapPoints={[…]}`
// would otherwise tear the observer down on every render.
const snapKey = snapPoints.join(",");
const points = React.useMemo(() => snapKey.split(",").map(Number), [snapKey]);
React.useEffect(() => {
const overlay = overlayRef.current;
if (!overlay) return;
const ro = new ResizeObserver(() => {
const heights = resolveHeights(points, overlay.clientHeight);
setMetrics({ heights, sheetHeight: heights[heights.length - 1] ?? 0 });
});
ro.observe(overlay);
return () => ro.disconnect();
}, [points]);
const { heights, sheetHeight } = metrics;
/** y for a detent: 0 is the tallest, `sheetHeight` is fully closed. */
const detentY = React.useMemo(
() => heights.map((h) => sheetHeight - h),
[heights, sheetHeight],
);
const closedY = sheetHeight;
const restingY = React.useCallback(
(index: number) => detentY[Math.min(index, detentY.length - 1)] ?? 0,
[detentY],
);
// --- open / close -------------------------------------------------------
React.useEffect(() => {
if (sheetHeight === 0) return;
const target = open ? restingY(snapIndex) : closedY;
if (reduce) {
y.set(target);
return;
}
const controls = animate(y, target, {
type: "spring",
stiffness: 420,
damping: 42,
restDelta: 0.5,
});
return () => controls.stop();
}, [open, snapIndex, sheetHeight, closedY, restingY, reduce, y]);
// --- focus --------------------------------------------------------------
React.useEffect(() => {
if (!open) return;
restoreRef.current = document.activeElement as HTMLElement | null;
const sheet = sheetRef.current;
const raf = requestAnimationFrame(() => {
const first = sheet?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? sheet)?.focus();
});
return () => {
cancelAnimationFrame(raf);
restoreRef.current?.focus?.();
};
}, [open]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onOpenChange(false);
return;
}
if (e.key !== "Tab") return;
const sheet = sheetRef.current;
if (!sheet) return;
const nodes = Array.from(sheet.querySelectorAll<HTMLElement>(FOCUSABLE));
if (nodes.length === 0) {
e.preventDefault();
return;
}
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
}
};
// --- gesture ------------------------------------------------------------
const beginGesture = (e: React.PointerEvent, fromHandle: boolean) => {
if (!open || e.button !== 0) return;
gesture.current = {
active: true,
mode: "none",
decided: fromHandle,
fromHandle,
startX: e.clientX,
startY: e.clientY,
startSheetY: y.get(),
pointerId: e.pointerId,
samples: [{ t: performance.now(), y: y.get() }],
};
if (fromHandle) {
gesture.current.mode = "sheet";
// Capture on the sheet, not the handle, so the whole gesture keeps
// reporting to one element no matter where the finger travels.
sheetRef.current?.setPointerCapture(e.pointerId);
}
};
const onPointerMove = (e: React.PointerEvent) => {
const g = gesture.current;
if (!g?.active) return;
const dy = e.clientY - g.startY;
// The arbitration: decided once, on the first move that clears the
// threshold, then locked. Nothing below re-runs it.
if (!g.decided) {
const dx = e.clientX - g.startX;
if (Math.abs(dy) < DECIDE_THRESHOLD && Math.abs(dx) < DECIDE_THRESHOLD) {
return;
}
g.decided = true;
const atTop = (contentRef.current?.scrollTop ?? 0) <= 0;
// Tallest detent is y === 0 by construction.
const atTallest = y.get() <= 1;
if (Math.abs(dx) > Math.abs(dy)) g.mode = "none";
else if (dy > 0 && atTop) g.mode = "sheet";
else if (dy < 0 && !atTallest) g.mode = "sheet";
else g.mode = "scroll";
if (g.mode === "sheet") {
sheetRef.current?.setPointerCapture(e.pointerId);
}
}
if (g.mode !== "sheet") return;
e.preventDefault();
const min = 0;
const max = dismissible ? closedY : (detentY[0] ?? 0);
const next = applyBounds(
g.startSheetY + dy,
min,
max,
sheetHeight,
resistance,
);
y.set(next);
const now = performance.now();
g.samples.push({ t: now, y: next });
// Keep only what the velocity window can see, plus a little slack.
while (g.samples.length > 2 && now - g.samples[0].t > VELOCITY_WINDOW * 2) {
g.samples.shift();
}
};
const endGesture = () => {
const g = gesture.current;
gesture.current = null;
if (!g?.active || g.mode !== "sheet") return;
const velocity = velocityFrom(g.samples, VELOCITY_WINDOW);
const targets = dismissible ? [...detentY, closedY] : [...detentY];
const target = projectedSnap(y.get(), velocity, targets, decay);
if (dismissible && target === closedY) {
onOpenChange(false);
return;
}
const index = detentY.indexOf(target);
if (index >= 0 && index !== snapIndex) {
setSnapIndex(index);
} else if (!reduce) {
// Same detent — the open/close effect won't re-run, so settle here,
// carrying the gesture velocity into the spring so a flick that lands
// back on its own detent still decelerates instead of stopping dead.
animate(y, target, {
type: "spring",
stiffness: 420,
damping: 42,
velocity,
restDelta: 0.5,
});
} else {
y.set(target);
}
};
// Backdrop tracks the sheet's travel, so it fades *with* the drag rather
// than on a separate timer that would visibly lag it.
const backdropOpacity = useTransform(
y,
[0, Math.max(1, sheetHeight)],
[1, 0],
{ clamp: true },
);
return (
<div
ref={overlayRef}
data-slot="drag-sheet-overlay"
inert={!open}
className={cn(
"inset-0 z-50 overflow-hidden",
position === "fixed" ? "fixed" : "absolute",
!open && "pointer-events-none",
)}
>
<motion.div
aria-hidden
onClick={() => onOpenChange(false)}
style={{ opacity: backdropOpacity }}
className="absolute inset-0 bg-zinc-950/40"
/>
<motion.div
ref={sheetRef}
data-slot="drag-sheet"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={onKeyDown}
onPointerDown={(e) => beginGesture(e, false)}
onPointerMove={onPointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
style={{ y, height: sheetHeight || undefined }}
className={cn(
"absolute inset-x-0 bottom-0 flex flex-col rounded-t-2xl border-t border-zinc-200 bg-white shadow-2xl outline-none dark:border-zinc-800 dark:bg-zinc-900",
className,
)}
>
{/* touch-action: none HERE ONLY — on the sheet it would kill scrolling. */}
<div
data-slot="drag-sheet-handle"
onPointerDown={(e) => beginGesture(e, true)}
style={{ touchAction: "none" }}
className="flex flex-none cursor-grab touch-none justify-center py-3 active:cursor-grabbing"
>
<div className="h-1 w-9 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
<h2
id={titleId}
className="flex-none px-5 pb-2 text-base font-semibold text-zinc-950 dark:text-zinc-50"
>
{title}
</h2>
<div
ref={contentRef}
data-slot="drag-sheet-content"
style={{ touchAction: "pan-y", overscrollBehavior: "contain" }}
className="min-h-0 flex-1 overflow-y-auto px-5 pb-6"
>
{children}
</div>
</motion.div>
</div>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/drag-sheet.json1. Install dependencies
Terminal
npm install motion clsx tailwind-merge2. Copy the source into your project
components/ui/drag-sheet.tsx
"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
/**
* DragSheet — a bottom sheet with snap points that behaves like a native one.
*
* "Native feel" here is not vibes. It is three formulas and a state machine,
* and all four are in this file.
*
* ## 1 · Velocity-projected snapping
*
* Do **not** snap to the nearest detent. Snap to the detent nearest to
* `position + velocity × decay` (decay ≈ 0.35s). This is the single change
* that separates a sheet that feels alive from one that feels like a `<div>`:
* a fast flick past the midpoint carries through to the next detent, while a
* slow drag past the same point falls back. Same position, different outcome,
* because intent lives in the velocity.
*
* Velocity is sampled over the **last ~100ms**, not the whole gesture. Average
* a two-second drag and a flick at the very end reads as barely moving.
*
* ## 2 · Rubber-band resistance
*
* Past the bounds, Apple's curve: `(1 − 1/(x·c/d + 1))·d` with `c ≈ 0.55`.
* It is asymptotic — displacement approaches `d` but never reaches it — so the
* sheet never runs away, and resistance grows the further you pull.
*
* ## 3 · Drag-vs-scroll arbitration — the actual difficulty
*
* The sheet contains scrollable content, so every gesture is ambiguous: is
* this dragging the sheet or scrolling its contents? The rule:
*
* - from the handle → always the sheet;
* - dragging **down** while the content is at `scrollTop === 0` → the sheet;
* - dragging **up** while the sheet is below its tallest detent → the sheet
* (it grows before its contents scroll, as iOS does);
* - anything else → let the content scroll, and never move the sheet.
*
* The part that matters is not the rule, it is *when* it is evaluated. The
* decision is made on the **first `pointermove` that clears a 4px threshold**
* and then **locked for the rest of the gesture**. Re-evaluating mid-gesture is
* precisely what makes every hand-rolled sheet feel broken: you drag down, the
* content hits `scrollTop === 0` halfway through, and the sheet suddenly grabs
* a gesture that started as a scroll.
*
* The 4px threshold is part of it — deciding on the very first pixel means a
* jitter at touch-down picks the axis, and it picks it wrong about half the
* time.
*
* ## Why `touch-action` is set the way it is
*
* `touch-action: none` on the **handle only**. Put it on the sheet and native
* content scrolling dies. The content keeps `pan-y` plus
* `overscroll-behavior: contain`, which is what makes the arbitration possible
* at all: at `scrollTop === 0` a downward pan has nowhere to scroll, so the
* browser doesn't claim the gesture and our `pointermove` handler is free to
* drive the sheet.
*
* ## Structure
*
* The sheet stays mounted and is `inert` + `aria-hidden` when closed, rather
* than unmounting. That makes open/close a single continuous animation on one
* motion value with no exit-animation bookkeeping, and `inert` keeps the closed
* content out of the accessibility tree and off the tab order properly.
*/
// ---------------------------------------------------------------------------
// The three formulas — pure, no DOM.
// ---------------------------------------------------------------------------
/**
* Apple's rubber-band curve. `overshoot` is signed; the result carries the
* same sign. Asymptotically approaches `dimension`, so resistance rises
* without bound but displacement does not.
*/
export function rubberBand(
overshoot: number,
dimension: number,
c = 0.55,
): number {
if (dimension <= 0 || overshoot === 0) return 0;
const x = Math.abs(overshoot);
const displaced = (1 - 1 / ((x * c) / dimension + 1)) * dimension;
return overshoot < 0 ? -displaced : displaced;
}
/** Free travel inside `[min, max]`, rubber-banded outside it. */
export function applyBounds(
y: number,
min: number,
max: number,
dimension: number,
c = 0.55,
): number {
if (y < min) return min + rubberBand(y - min, dimension, c);
if (y > max) return max + rubberBand(y - max, dimension, c);
return y;
}
/**
* The snap target, chosen by where the sheet is *heading* rather than where it
* currently is. `decay` is how far into the future to look, in seconds.
*/
export function projectedSnap(
y: number,
velocity: number,
snaps: number[],
decay = 0.35,
): number {
const projected = y + velocity * decay;
let best = snaps[0];
let bestDistance = Infinity;
for (const s of snaps) {
const d = Math.abs(s - projected);
if (d < bestDistance) {
bestDistance = d;
best = s;
}
}
return best;
}
/** Velocity in px/s from the tail of a sample buffer. */
export function velocityFrom(
samples: { t: number; y: number }[],
windowMs = 100,
): number {
if (samples.length < 2) return 0;
const last = samples[samples.length - 1];
let first = samples[0];
for (const s of samples) {
if (last.t - s.t <= windowMs) {
first = s;
break;
}
}
const dt = (last.t - first.t) / 1000;
if (dt <= 0) return 0;
return (last.y - first.y) / dt;
}
/** Resolve snap points: values ≤ 1 are fractions of `containerHeight`. */
export function resolveHeights(
snapPoints: number[],
containerHeight: number,
): number[] {
return snapPoints
.map((p) => (p <= 1 ? p * containerHeight : p))
.filter((h) => h > 0)
.sort((a, b) => a - b);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const DECIDE_THRESHOLD = 4;
const VELOCITY_WINDOW = 100;
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
type GestureMode = "sheet" | "scroll" | "none";
export interface DragSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Detent heights, ascending. Values ≤ 1 are fractions of the container's
* height; larger values are pixels.
*/
snapPoints?: number[];
/** Detent index used the first time the sheet opens. */
initialSnap?: number;
/** Allow dragging down past the smallest detent to dismiss. */
dismissible?: boolean;
/** How far ahead the velocity projection looks, in seconds. */
decay?: number;
/** Rubber-band constant. Lower = stiffer. */
resistance?: number;
/** Accessible title for the dialog. */
title: string;
children?: React.ReactNode;
/** `fixed` covers the viewport; `absolute` scopes it to a positioned parent. */
position?: "fixed" | "absolute";
className?: string;
}
export function DragSheet({
open,
onOpenChange,
snapPoints = [0.45, 0.92],
initialSnap = 0,
dismissible = true,
decay = 0.35,
resistance = 0.55,
title,
children,
position = "fixed",
className,
}: DragSheetProps) {
const overlayRef = React.useRef<HTMLDivElement>(null);
const sheetRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const restoreRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
const y = useMotionValue(0);
const [metrics, setMetrics] = React.useState({
heights: [] as number[],
sheetHeight: 0,
});
const [snapIndex, setSnapIndex] = React.useState(initialSnap);
const reduce =
typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches;
// Live gesture state — refs, because none of it should re-render anything.
const gesture = React.useRef<{
active: boolean;
mode: GestureMode;
decided: boolean;
fromHandle: boolean;
startX: number;
startY: number;
startSheetY: number;
pointerId: number;
samples: { t: number; y: number }[];
} | null>(null);
// --- metrics ------------------------------------------------------------
// Keyed on the values, not the array identity: an inline `snapPoints={[…]}`
// would otherwise tear the observer down on every render.
const snapKey = snapPoints.join(",");
const points = React.useMemo(() => snapKey.split(",").map(Number), [snapKey]);
React.useEffect(() => {
const overlay = overlayRef.current;
if (!overlay) return;
const ro = new ResizeObserver(() => {
const heights = resolveHeights(points, overlay.clientHeight);
setMetrics({ heights, sheetHeight: heights[heights.length - 1] ?? 0 });
});
ro.observe(overlay);
return () => ro.disconnect();
}, [points]);
const { heights, sheetHeight } = metrics;
/** y for a detent: 0 is the tallest, `sheetHeight` is fully closed. */
const detentY = React.useMemo(
() => heights.map((h) => sheetHeight - h),
[heights, sheetHeight],
);
const closedY = sheetHeight;
const restingY = React.useCallback(
(index: number) => detentY[Math.min(index, detentY.length - 1)] ?? 0,
[detentY],
);
// --- open / close -------------------------------------------------------
React.useEffect(() => {
if (sheetHeight === 0) return;
const target = open ? restingY(snapIndex) : closedY;
if (reduce) {
y.set(target);
return;
}
const controls = animate(y, target, {
type: "spring",
stiffness: 420,
damping: 42,
restDelta: 0.5,
});
return () => controls.stop();
}, [open, snapIndex, sheetHeight, closedY, restingY, reduce, y]);
// --- focus --------------------------------------------------------------
React.useEffect(() => {
if (!open) return;
restoreRef.current = document.activeElement as HTMLElement | null;
const sheet = sheetRef.current;
const raf = requestAnimationFrame(() => {
const first = sheet?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? sheet)?.focus();
});
return () => {
cancelAnimationFrame(raf);
restoreRef.current?.focus?.();
};
}, [open]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onOpenChange(false);
return;
}
if (e.key !== "Tab") return;
const sheet = sheetRef.current;
if (!sheet) return;
const nodes = Array.from(sheet.querySelectorAll<HTMLElement>(FOCUSABLE));
if (nodes.length === 0) {
e.preventDefault();
return;
}
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
}
};
// --- gesture ------------------------------------------------------------
const beginGesture = (e: React.PointerEvent, fromHandle: boolean) => {
if (!open || e.button !== 0) return;
gesture.current = {
active: true,
mode: "none",
decided: fromHandle,
fromHandle,
startX: e.clientX,
startY: e.clientY,
startSheetY: y.get(),
pointerId: e.pointerId,
samples: [{ t: performance.now(), y: y.get() }],
};
if (fromHandle) {
gesture.current.mode = "sheet";
// Capture on the sheet, not the handle, so the whole gesture keeps
// reporting to one element no matter where the finger travels.
sheetRef.current?.setPointerCapture(e.pointerId);
}
};
const onPointerMove = (e: React.PointerEvent) => {
const g = gesture.current;
if (!g?.active) return;
const dy = e.clientY - g.startY;
// The arbitration: decided once, on the first move that clears the
// threshold, then locked. Nothing below re-runs it.
if (!g.decided) {
const dx = e.clientX - g.startX;
if (Math.abs(dy) < DECIDE_THRESHOLD && Math.abs(dx) < DECIDE_THRESHOLD) {
return;
}
g.decided = true;
const atTop = (contentRef.current?.scrollTop ?? 0) <= 0;
// Tallest detent is y === 0 by construction.
const atTallest = y.get() <= 1;
if (Math.abs(dx) > Math.abs(dy)) g.mode = "none";
else if (dy > 0 && atTop) g.mode = "sheet";
else if (dy < 0 && !atTallest) g.mode = "sheet";
else g.mode = "scroll";
if (g.mode === "sheet") {
sheetRef.current?.setPointerCapture(e.pointerId);
}
}
if (g.mode !== "sheet") return;
e.preventDefault();
const min = 0;
const max = dismissible ? closedY : (detentY[0] ?? 0);
const next = applyBounds(
g.startSheetY + dy,
min,
max,
sheetHeight,
resistance,
);
y.set(next);
const now = performance.now();
g.samples.push({ t: now, y: next });
// Keep only what the velocity window can see, plus a little slack.
while (g.samples.length > 2 && now - g.samples[0].t > VELOCITY_WINDOW * 2) {
g.samples.shift();
}
};
const endGesture = () => {
const g = gesture.current;
gesture.current = null;
if (!g?.active || g.mode !== "sheet") return;
const velocity = velocityFrom(g.samples, VELOCITY_WINDOW);
const targets = dismissible ? [...detentY, closedY] : [...detentY];
const target = projectedSnap(y.get(), velocity, targets, decay);
if (dismissible && target === closedY) {
onOpenChange(false);
return;
}
const index = detentY.indexOf(target);
if (index >= 0 && index !== snapIndex) {
setSnapIndex(index);
} else if (!reduce) {
// Same detent — the open/close effect won't re-run, so settle here,
// carrying the gesture velocity into the spring so a flick that lands
// back on its own detent still decelerates instead of stopping dead.
animate(y, target, {
type: "spring",
stiffness: 420,
damping: 42,
velocity,
restDelta: 0.5,
});
} else {
y.set(target);
}
};
// Backdrop tracks the sheet's travel, so it fades *with* the drag rather
// than on a separate timer that would visibly lag it.
const backdropOpacity = useTransform(
y,
[0, Math.max(1, sheetHeight)],
[1, 0],
{ clamp: true },
);
return (
<div
ref={overlayRef}
data-slot="drag-sheet-overlay"
inert={!open}
className={cn(
"inset-0 z-50 overflow-hidden",
position === "fixed" ? "fixed" : "absolute",
!open && "pointer-events-none",
)}
>
<motion.div
aria-hidden
onClick={() => onOpenChange(false)}
style={{ opacity: backdropOpacity }}
className="absolute inset-0 bg-zinc-950/40"
/>
<motion.div
ref={sheetRef}
data-slot="drag-sheet"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={onKeyDown}
onPointerDown={(e) => beginGesture(e, false)}
onPointerMove={onPointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
style={{ y, height: sheetHeight || undefined }}
className={cn(
"absolute inset-x-0 bottom-0 flex flex-col rounded-t-2xl border-t border-zinc-200 bg-white shadow-2xl outline-none dark:border-zinc-800 dark:bg-zinc-900",
className,
)}
>
{/* touch-action: none HERE ONLY — on the sheet it would kill scrolling. */}
<div
data-slot="drag-sheet-handle"
onPointerDown={(e) => beginGesture(e, true)}
style={{ touchAction: "none" }}
className="flex flex-none cursor-grab touch-none justify-center py-3 active:cursor-grabbing"
>
<div className="h-1 w-9 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
<h2
id={titleId}
className="flex-none px-5 pb-2 text-base font-semibold text-zinc-950 dark:text-zinc-50"
>
{title}
</h2>
<div
ref={contentRef}
data-slot="drag-sheet-content"
style={{ touchAction: "pan-y", overscrollBehavior: "contain" }}
className="min-h-0 flex-1 overflow-y-auto px-5 pb-6"
>
{children}
</div>
</motion.div>
</div>
);
}lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
open / onOpenChange | boolean / (open: boolean) => void | — | Controlled open state. The sheet stays mounted and goes `inert` when closed, so opening and closing is one continuous animation on a single motion value rather than an exit-animation dance. |
snapPoints | number[] | [0.45, 0.92] | Detent heights, ascending. Values ≤ 1 are fractions of the container's height; larger values are pixels. The two may be mixed. |
initialSnap | number | 0 | Detent index used the first time the sheet opens. Reopening keeps whichever detent it was left at, as native sheets do. |
dismissible | boolean | true | Allow dragging down past the smallest detent to close. Dismissal is just another snap target, so a downward flick can carry into it. |
decay | number | 0.35 | How far ahead the velocity projection looks, in seconds. This is the number that decides whether a flick carries through — raise it and the sheet feels eager, drop it to 0 and it becomes plain nearest-snap. |
resistance | number | 0.55 | Rubber-band constant `c`. Lower is stiffer. Displacement is asymptotic to the sheet height, so it always resists more the further you pull and never runs away. |
position | "fixed" | "absolute" | "fixed" | `fixed` covers the viewport; `absolute` scopes the sheet to the nearest positioned ancestor, which is what makes it embeddable in a card or a device frame. |
title | string | — | Required. Labels the `role="dialog"` via `aria-labelledby`; the sheet also traps Tab, closes on Escape, and restores focus on close. |
Dependencies
motionclsxtailwind-merge