Overview
A page-level bottom sheet driven by a single motion value: open, drag and close all move the same y, so there is never a seam between the CSS and the gesture. The scrim reads that value too and clears in step with your finger. Release velocity hands off to a critically damped spring, the sheet rubber-bands when pulled up (with paper underneath, so no gap shows), and grabbing it mid-close catches it where it is. Optional description, product screen in browser chrome, and a pinned footer for actions.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/spring-drawer.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, {4 cloneElement,5 isValidElement,6 useCallback,7 useEffect,8 useId,9 useLayoutEffect,10 useRef,11 useState,12} from "react";13import { createPortal } from "react-dom";14import {15 animate,16 motion,17 useMotionValue,18 useReducedMotion,19 useTransform,20 type AnimationPlaybackControlsWithThen,21} from "motion/react";22import { X } from "lucide-react";23import { cn } from "@/lib/utils";24 25export type SpringDrawerProps = {26 open?: boolean;27 defaultOpen?: boolean;28 onOpenChange?: (open: boolean) => void;29 title?: string;30 /** Secondary line under the title. */31 description?: string;32 children: React.ReactNode;33 /** Actions pinned to the bottom of the sheet. */34 footer?: React.ReactNode;35 className?: string;36 /** Custom trigger. A single element gets the open handler and ARIA wired in. */37 trigger?: React.ReactNode;38 triggerLabel?: string;39 /** Product screenshot shown as a page screen */40 image?: string;41 /** Optional URL shown in the browser chrome */42 url?: string;43 /**44 * @deprecated Prefer page-level (fixed) drawers.45 * Contained mode clips into a local frame — avoid for demos.46 */47 contained?: boolean;48};49 50const DECEL = 0.998;51/** Parked well below any sheet while closed. */52const OFFSCREEN = 4000;53const OPEN_SPRING = { type: "spring" as const, bounce: 0, duration: 0.42 };54const CLOSE_SPRING = { type: "spring" as const, bounce: 0, duration: 0.32 };55 56function project(velocity: number, decelerationRate = DECEL) {57 return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);58}59 60function rubberband(overshoot: number, dimension: number, constant = 0.55) {61 return (62 (overshoot * dimension * constant) /63 (dimension + constant * Math.abs(overshoot))64 );65}66 67const FOCUSABLE =68 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';69 70export function SpringDrawer({71 open: openControlled,72 defaultOpen = false,73 onOpenChange,74 title = "Drawer",75 description,76 children,77 footer,78 className,79 trigger,80 triggerLabel = "Open drawer",81 image,82 url,83 contained = false,84}: SpringDrawerProps) {85 const reduce = useReducedMotion();86 const titleId = useId();87 const descId = useId();88 const dialogId = useId();89 const sheetRef = useRef<HTMLDivElement>(null);90 const closeRef = useRef<HTMLButtonElement>(null);91 const returnFocusRef = useRef<HTMLElement | null>(null);92 const [mounted, setMounted] = useState(false);93 const [uncontrolled, setUncontrolled] = useState(defaultOpen);94 const open = openControlled ?? uncontrolled;95 const [present, setPresent] = useState(open);96 97 useEffect(() => setMounted(true), []);98 99 const setOpen = useCallback(100 (next: boolean) => {101 if (openControlled === undefined) setUncontrolled(next);102 onOpenChange?.(next);103 },104 [onOpenChange, openControlled],105 );106 107 // One value drives the sheet for open, drag and close; the scrim reads it108 // too, so it dims and clears in step with the finger.109 const y = useMotionValue(OFFSCREEN);110 const height = useMotionValue(420);111 const fade = useMotionValue(reduce ? 0 : 1);112 const progress = useTransform(() => {113 const h = height.get() || 1;114 return Math.min(1, Math.max(0, 1 - y.get() / h));115 });116 const scrimOpacity = useTransform(() => (reduce ? fade.get() : progress.get()));117 118 const anim = useRef<AnimationPlaybackControlsWithThen | null>(null);119 const run = (a: AnimationPlaybackControlsWithThen) => {120 anim.current?.stop();121 anim.current = a;122 return a;123 };124 const closeToken = useRef(0);125 const releaseVelocity = useRef(0);126 // Set when a drag re-grabs a closing sheet: it is already where the finger is.127 const skipOpenAnim = useRef(false);128 129 const close = useCallback(130 (velocity = 0) => {131 releaseVelocity.current = velocity;132 setOpen(false);133 },134 [setOpen],135 );136 137 useEffect(() => {138 if (open) {139 closeToken.current++;140 setPresent(true);141 }142 }, [open]);143 144 // Enter: measure, park just below the fold, spring up. Re-opening while a145 // close is in flight retargets from wherever the sheet is.146 useLayoutEffect(() => {147 if (!present || !open) return;148 const el = sheetRef.current;149 if (!el) return;150 const h = el.offsetHeight;151 height.set(h);152 if (skipOpenAnim.current) {153 skipOpenAnim.current = false;154 return;155 }156 if (reduce) {157 y.set(0);158 run(animate(fade, 1, { duration: 0.18, ease: "easeOut" }));159 return;160 }161 fade.set(1);162 if (y.get() > h + 24) y.set(h + 24);163 run(animate(y, 0, OPEN_SPRING));164 // eslint-disable-next-line react-hooks/exhaustive-deps165 }, [present, open, reduce]);166 167 // Exit along the same path, carrying any release velocity.168 useEffect(() => {169 if (open || !present) return;170 const token = ++closeToken.current;171 const v = releaseVelocity.current;172 releaseVelocity.current = 0;173 const done = () => {174 if (closeToken.current !== token) return;175 setPresent(false);176 y.set(OFFSCREEN);177 };178 if (reduce) {179 run(animate(fade, 0, { duration: 0.15, ease: "easeIn" })).then(done);180 } else {181 run(animate(y, height.get() + 24, { ...CLOSE_SPRING, velocity: v })).then(done);182 }183 // eslint-disable-next-line react-hooks/exhaustive-deps184 }, [open]);185 186 // Keep the measured height current (content, fonts, images, rotation).187 useEffect(() => {188 if (!present) return;189 const el = sheetRef.current;190 if (!el) return;191 const ro = new ResizeObserver(() => height.set(el.offsetHeight));192 ro.observe(el);193 return () => ro.disconnect();194 }, [present, height]);195 196 // Focus in on open, back to the trigger after the exit finishes.197 useEffect(() => {198 if (open && present && mounted) {199 if (!returnFocusRef.current && document.activeElement instanceof HTMLElement) {200 returnFocusRef.current = document.activeElement;201 }202 closeRef.current?.focus({ preventScroll: true });203 } else if (!open && !present && returnFocusRef.current) {204 returnFocusRef.current.focus({ preventScroll: true });205 returnFocusRef.current = null;206 }207 }, [open, present, mounted]);208 209 // Scroll lock without the page shifting under the scrollbar gutter.210 useEffect(() => {211 if (!present || contained) return;212 const { body, documentElement } = document;213 const gutter = window.innerWidth - documentElement.clientWidth;214 const prev = { overflow: body.style.overflow, paddingRight: body.style.paddingRight };215 body.style.overflow = "hidden";216 if (gutter > 0) body.style.paddingRight = `${gutter}px`;217 return () => {218 body.style.overflow = prev.overflow;219 body.style.paddingRight = prev.paddingRight;220 };221 }, [present, contained]);222 223 // Escape + focus trap224 useEffect(() => {225 if (!present || !open) return;226 const onKey = (e: KeyboardEvent) => {227 if (e.key === "Escape") {228 e.preventDefault();229 close();230 return;231 }232 if (e.key !== "Tab") return;233 const panel = sheetRef.current;234 if (!panel) return;235 const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(236 (el) => !el.closest('[aria-hidden="true"]') && el.getClientRects().length > 0,237 );238 if (!focusable.length) {239 e.preventDefault();240 panel.focus();241 return;242 }243 const first = focusable[0];244 const last = focusable[focusable.length - 1];245 if (e.shiftKey && document.activeElement === first) {246 e.preventDefault();247 last.focus();248 } else if (!e.shiftKey && document.activeElement === last) {249 e.preventDefault();250 first.focus();251 }252 };253 const containFocus = (event: FocusEvent) => {254 const panel = sheetRef.current;255 if (panel && !panel.contains(event.target as Node)) {256 (panel.querySelector<HTMLElement>(FOCUSABLE) ?? panel).focus();257 }258 };259 window.addEventListener("keydown", onKey);260 document.addEventListener("focusin", containFocus);261 return () => {262 window.removeEventListener("keydown", onKey);263 document.removeEventListener("focusin", containFocus);264 };265 }, [open, present, close]);266 267 useEffect(() => () => anim.current?.stop(), []);268 269 /* ── Drag ─────────────────────────────────────────────── */270 271 const dragging = useRef(false);272 const startY = useRef(0);273 const startOffset = useRef(0);274 const samples = useRef<{ t: number; y: number }[]>([]);275 276 const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {277 if (reduce || !e.isPrimary || e.button !== 0) return;278 if (e.target instanceof Element && e.target.closest("[data-drawer-no-drag]")) return;279 // Grabbing mid-flight catches the sheet where it is, even mid-close.280 anim.current?.stop();281 if (!open) {282 closeToken.current++;283 skipOpenAnim.current = true;284 setOpen(true);285 }286 dragging.current = true;287 startY.current = e.clientY;288 startOffset.current = y.get();289 samples.current = [{ t: performance.now(), y: e.clientY }];290 e.currentTarget.setPointerCapture(e.pointerId);291 };292 293 const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {294 if (!dragging.current) return;295 const h = height.get();296 let next = startOffset.current + (e.clientY - startY.current);297 if (next < 0) next = -rubberband(-next, h * 0.4);298 y.set(next);299 samples.current.push({ t: performance.now(), y: e.clientY });300 if (samples.current.length > 5) samples.current.shift();301 };302 303 const velocity = () => {304 const s = samples.current;305 if (s.length < 2) return 0;306 const a = s[0];307 const b = s[s.length - 1];308 const dt = (b.t - a.t) / 1000;309 return dt > 0 ? (b.y - a.y) / dt : 0;310 };311 312 const onPointerUp = () => {313 if (!dragging.current) return;314 dragging.current = false;315 const vy = velocity();316 const projected = y.get() + project(vy);317 if (projected > height.get() * 0.3 || vy > 500) {318 close(vy);319 } else {320 run(321 animate(y, 0, {322 ...OPEN_SPRING,323 bounce: Math.abs(vy) > 500 ? 0.1 : 0,324 velocity: vy,325 }),326 );327 }328 };329 330 const dragHandlers = {331 onPointerDown,332 onPointerMove,333 onPointerUp,334 onPointerCancel: onPointerUp,335 };336 337 const layer = contained ? "absolute" : "fixed";338 339 const overlay = present ? (340 <>341 <motion.div342 aria-hidden="true"343 className={cn(layer, "inset-0 z-100 bg-black/40 backdrop-blur-[2px]")}344 style={{ opacity: scrimOpacity }}345 onClick={() => close()}346 />347 <motion.div348 ref={sheetRef}349 id={dialogId}350 role="dialog"351 aria-modal="true"352 aria-labelledby={titleId}353 aria-describedby={description ? descId : undefined}354 tabIndex={-1}355 style={{ y, opacity: reduce ? fade : 1 }}356 className={cn(357 layer,358 "inset-x-0 bottom-0 z-110 mx-auto flex max-h-[88dvh] w-full max-w-lg flex-col outline-none",359 "rounded-t-[calc(var(--radius-lg)+6px)] border border-b-0 border-(--color-rule)",360 "bg-(--color-paper) shadow-[0_-16px_48px_rgba(0,0,0,0.16)]",361 "dark:shadow-[0_-16px_48px_rgba(0,0,0,0.5)]",362 )}363 >364 {/* Fills the gap under the sheet while it rubber-bands upward */}365 <div366 aria-hidden367 className="absolute inset-x-[-1px] top-full h-[50vh] border-x border-(--color-rule) bg-(--color-paper)"368 />369 370 <div371 {...dragHandlers}372 className="shrink-0 cursor-grab touch-none select-none active:cursor-grabbing"373 >374 <div className="flex justify-center pt-2.5 pb-1.5">375 <div className="h-1 w-10 rounded-full bg-(--color-ink)/15" />376 </div>377 378 <div className="flex items-start gap-3 px-5 pt-1 pb-4">379 <div className="min-w-0 flex-1">380 <h2381 id={titleId}382 className="font-(family-name:--font-display) text-[17px] font-medium tracking-[-0.02em] text-balance text-(--color-ink)"383 >384 {title}385 </h2>386 {description ? (387 <div id={descId} className="mt-0.5 text-[13px] text-(--color-neutral)">388 {description}389 </div>390 ) : null}391 </div>392 <button393 ref={closeRef}394 type="button"395 aria-label="Close drawer"396 data-drawer-no-drag397 onClick={() => close()}398 className={cn(399 "-mt-1 -mr-2 grid size-9 shrink-0 place-items-center rounded-full",400 "bg-(--color-paper-2) text-(--color-ink-2)",401 "transition-[background-color,color,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",402 "hover:bg-(--color-rule) hover:text-(--color-ink) active:scale-[0.94]",403 "outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",404 )}405 >406 <X className="size-4" aria-hidden="true" />407 </button>408 </div>409 410 {image ? (411 <div className="mx-4 mb-1 overflow-hidden rounded-md border border-(--color-rule) bg-(--color-paper-2)">412 <div className="flex items-center gap-2.5 border-b border-(--color-rule) px-3 py-1.5">413 <div className="flex gap-1" aria-hidden>414 <span className="size-1.5 rounded-full bg-(--color-ink)/15" />415 <span className="size-1.5 rounded-full bg-(--color-ink)/15" />416 <span className="size-1.5 rounded-full bg-(--color-ink)/15" />417 </div>418 {url ? (419 <span className="mx-auto min-w-0 truncate rounded-sm bg-(--color-paper) px-2 py-0.5 font-mono text-[10px] text-(--color-neutral)">420 {url}421 </span>422 ) : null}423 <span className="w-[22px]" aria-hidden />424 </div>425 {/* eslint-disable-next-line @next/next/no-img-element */}426 <img427 src={image}428 alt=""429 draggable={false}430 className="aspect-16/10 w-full object-cover object-top"431 />432 </div>433 ) : null}434 </div>435 436 <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-3 pb-5">437 <div className="text-[13px] leading-relaxed text-pretty text-(--color-ink-2) sm:text-[14px]">438 {children}439 </div>440 </div>441 442 {footer ? (443 <div className="flex shrink-0 gap-2 border-t border-(--color-rule) px-5 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">444 {footer}445 </div>446 ) : (447 <div aria-hidden className="h-[env(safe-area-inset-bottom)] shrink-0" />448 )}449 </motion.div>450 </>451 ) : null;452 453 const openSheet = () => setOpen(true);454 const triggerProps = {455 "aria-expanded": open,456 "aria-controls": dialogId,457 "aria-haspopup": "dialog" as const,458 };459 460 let triggerNode: React.ReactNode;461 if (isValidElement<React.HTMLAttributes<HTMLElement>>(trigger)) {462 const own = trigger.props.onClick;463 triggerNode = cloneElement(trigger, {464 ...triggerProps,465 onClick: (e: React.MouseEvent<HTMLElement>) => {466 own?.(e);467 if (!e.defaultPrevented) openSheet();468 },469 });470 } else if (trigger) {471 triggerNode = trigger;472 } else {473 triggerNode = (474 <button475 {...triggerProps}476 type="button"477 onClick={openSheet}478 className={cn(479 "min-h-10 rounded-full border border-(--color-rule) bg-(--color-paper) px-4",480 "font-(family-name:--font-display) text-[13px] font-medium tracking-tight text-(--color-ink)",481 "shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-[transform,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",482 "hover:bg-(--color-paper-2) focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:outline-none active:scale-[0.96]",483 )}484 >485 {triggerLabel}486 </button>487 );488 }489 490 return (491 <div className={cn("relative", className)}>492 {triggerNode}493 {contained ? overlay : mounted ? createPortal(overlay, document.body) : null}494 </div>495 );496}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { SpringDrawer } from "@/components/ui/spring-drawer";2 3<SpringDrawer title="Notes" triggerLabel="Open">4 <p>Sheet content</p>5</SpringDrawer>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| children | React.ReactNode | Yes | — | Content inside the sheet body. |
| title | string | No | "Drawer" | Accessible dialog title. |
| description | string | No | — | Secondary line under the title; wired to aria-describedby. |
| footer | React.ReactNode | No | — | Actions pinned to the bottom of the sheet, above the safe area. |
| open | boolean | No | — | Controlled open state. |
| defaultOpen | boolean | No | false | Uncontrolled initial open state. |
| onOpenChange | (open: boolean) => void | No | — | Called when open state changes. |
| triggerLabel | string | No | "Open drawer" | Label for the default trigger button. |
| trigger | React.ReactNode | No | — | Custom trigger. A single element gets the open handler and ARIA attributes wired in. |
| image | string | No | — | Product screenshot rendered in browser chrome under the handle. |
| url | string | No | — | Optional URL shown in the browser chrome. |
| contained | boolean | No | false | Deprecated — prefer page-level fixed sheets. Local clipping for rare embeds. |
Best Practices
- Never lock input during the close spring — grabbing the sheet mid-close catches it.
- Use a dimming scrim for modal focus; Escape and scrim share the dismiss path.
- Hand off release velocity into the spring so drag and settle feel continuous.
- Under reduced motion, crossfade opacity instead of translating the sheet.
- Lock page scroll without shifting layout: pad the body by the scrollbar gutter.