A bottom sheet you can fling away. Drag past 40% of its height — or flick it down fast — to dismiss; anything less springs back. Pulling up rubber-bands.
GSAP
Pointer Events
Gesture
Live demo
Live · try it with mouse, touch, or keyboard
How it’s built
Dragging is hand-rolled on Pointer Events with setPointerCapture, so the gesture survives the pointer leaving the grabber (mouse + touch alike).
Release reads distance and velocity (px/ms from event timestamps): past the threshold it dismisses, otherwise GSAP springs it back to rest.
Upward pulls past rest are damped to 25% for a rubber-band feel; touch-action:none stops the page scrolling under the drag.
Keyboard users get an explicit Close button and Escape; focus moves into the sheet on open and back to the trigger on close.
Accessibility
Explicit Close button + Escape — the gesture is an enhancement, not the only way out.
Focus moves into the sheet on open and returns to the trigger on close.
Source
The exact component, read straight from the repo at build. Requires gsap and @gsap/react.
DragSheetDemo.tsx
"use client";
import { useCallback, useRef, useState } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { X } from "lucide-react";
import { prefersReducedMotion } from "@/lib/motion";
gsap.registerPlugin(useGSAP);
// Past this fraction of the sheet's height — or this downward flick speed — a
// release dismisses; otherwise it springs back to rest.
const DISMISS_DISTANCE = 0.4; // of sheet height
const DISMISS_VELOCITY = 0.6; // px per ms, downward
/**
* A contained bottom sheet you can fling away. Dragging is hand-rolled on
* Pointer Events (works for touch + mouse, capture so it survives leaving the
* grabber); GSAP only runs the open / snap-back / dismiss tweens. Pulling up
* past rest rubber-bands. Keyboard users get an explicit Close button + Escape;
* reduced motion drops the tweens to instant.
*/
export function DragSheetDemo() {
const stageRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const openBtnRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
// Whether this open was triggered from the keyboard (click detail === 0) — if
// so the sheet appears instantly; animating keyboard-driven UI is disorienting.
const viaKeyboard = useRef(false);
const drag = useRef({
active: false,
startY: 0,
lastY: 0,
lastT: 0,
height: 0,
dy: 0,
vel: 0,
});
const reduce = () => prefersReducedMotion();
// Animate in whenever the sheet mounts, and drop focus onto it.
useGSAP(
() => {
if (!open) return;
const sheet = sheetRef.current;
const bd = backdropRef.current;
if (!sheet || !bd) return;
const instant = reduce() || viaKeyboard.current;
gsap.fromTo(
sheet,
{ y: sheet.offsetHeight },
{ y: 0, duration: instant ? 0 : 0.42, ease: "power3.out" },
);
gsap.fromTo(
bd,
{ autoAlpha: 0 },
{ autoAlpha: 1, duration: instant ? 0 : 0.3, ease: "power2.out" },
);
sheet.focus();
},
{ dependencies: [open], scope: stageRef },
);
const close = useCallback(() => {
const sheet = sheetRef.current;
const bd = backdropRef.current;
if (!sheet) {
setOpen(false);
return;
}
gsap.to(sheet, {
y: sheet.offsetHeight,
duration: reduce() ? 0 : 0.3,
ease: "power2.out",
onComplete: () => {
setOpen(false);
openBtnRef.current?.focus();
},
});
if (bd) gsap.to(bd, { autoAlpha: 0, duration: reduce() ? 0 : 0.25 });
}, []);
const onPointerDown = (e: React.PointerEvent) => {
const sheet = sheetRef.current;
if (!sheet) return;
e.currentTarget.setPointerCapture(e.pointerId);
gsap.killTweensOf(sheet);
drag.current = {
active: true,
startY: e.clientY,
lastY: e.clientY,
lastT: e.timeStamp,
height: sheet.offsetHeight,
dy: 0,
vel: 0,
};
};
const onPointerMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d.active) return;
let dy = e.clientY - d.startY;
// Resist upward pulls (past rest) so the sheet feels anchored.
if (dy < 0) dy *= 0.25;
dy = Math.min(dy, d.height);
d.vel = (e.clientY - d.lastY) / Math.max(1, e.timeStamp - d.lastT);
d.lastY = e.clientY;
d.lastT = e.timeStamp;
d.dy = dy;
gsap.set(sheetRef.current, { y: dy });
};
const onPointerUp = (e: React.PointerEvent) => {
const d = drag.current;
if (!d.active) return;
d.active = false;
e.currentTarget.releasePointerCapture(e.pointerId);
const dismiss =
d.dy > d.height * DISMISS_DISTANCE || d.vel > DISMISS_VELOCITY;
if (dismiss) {
close();
} else {
gsap.to(sheetRef.current, {
y: 0,
duration: reduce() ? 0 : 0.4,
ease: "power3.out",
});
}
};
return (
<div ref={stageRef} className="relative h-full w-full overflow-hidden">
<div className="flex h-full w-full items-center justify-center">
<button
ref={openBtnRef}
type="button"
onClick={(e) => {
viaKeyboard.current = e.detail === 0;
setOpen(true);
}}
className="rounded-full bg-accent px-5 py-2.5 text-sm font-semibold text-accent-foreground outline-none transition-transform duration-200 hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
Open sheet
</button>
</div>
{open && (
<>
<div
ref={backdropRef}
aria-hidden
onClick={close}
className="absolute inset-0 bg-black/55 backdrop-blur-[2px]"
/>
<div
ref={sheetRef}
role="dialog"
aria-label="Draggable demo sheet"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === "Escape") close();
}}
className="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-white/15 bg-elevated/95 shadow-[0_-20px_50px_-20px_rgb(0_0_0_/_0.8)] outline-none backdrop-blur-xl"
>
{/* Grabber — the only drag surface; touch-action:none stops the page
from scrolling under the gesture. */}
<div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{ touchAction: "none" }}
className="flex cursor-grab justify-center py-3 active:cursor-grabbing"
>
<span
aria-hidden
className="h-1.5 w-10 rounded-full bg-foreground/30"
/>
</div>
<div className="px-6 pb-7">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-display text-lg font-bold">
Drag me down
</h3>
<p className="mt-1 text-sm text-foreground/65">
Fling past 40% or with a quick flick to dismiss — otherwise
it springs back.
</p>
</div>
<button
type="button"
onClick={close}
aria-label="Close sheet"
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-foreground/70 outline-none transition-colors hover:bg-white/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
</>
)}
</div>
);
}
Usage
usage.tsx
import { DragSheetDemo } from "@/components/craft/DragSheetDemo";
<DragSheetDemo />