Overview
A page-level bottom sheet — not a clipped preview frame. Tracks the finger 1:1, rubber-bands past edges, then hands release velocity to a critically damped spring. Enter and exit share the same vertical path. Product screens render in a light browser chrome.
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, { useCallback, useEffect, useId, useRef, useState } from "react";4import { createPortal } from "react-dom";5import {6 motion,7 useMotionValue,8 animate,9 useReducedMotion,10} from "motion/react";11import { cn } from "@/lib/utils";12 13export type SpringDrawerProps = {14 open?: boolean;15 defaultOpen?: boolean;16 onOpenChange?: (open: boolean) => void;17 title?: string;18 children: React.ReactNode;19 className?: string;20 trigger?: React.ReactNode;21 triggerLabel?: string;22 /** Product screenshot shown as a page screen */23 image?: string;24 /** Optional URL shown in the browser chrome */25 url?: string;26 /**27 * @deprecated Prefer page-level (fixed) drawers.28 * Contained mode clips into a local frame — avoid for demos.29 */30 contained?: boolean;31};32 33const DECEL = 0.998;34 35function project(velocity: number, decelerationRate = DECEL) {36 return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);37}38 39function rubberband(overshoot: number, dimension: number, constant = 0.55) {40 return (41 (overshoot * dimension * constant) /42 (dimension + constant * Math.abs(overshoot))43 );44}45 46function readPanelCloseMs() {47 const v = parseFloat(48 getComputedStyle(document.documentElement).getPropertyValue(49 "--panel-close-dur",50 ),51 );52 return Number.isFinite(v) ? v : 350;53}54 55export function SpringDrawer({56 open: openControlled,57 defaultOpen = false,58 onOpenChange,59 title = "Drawer",60 children,61 className,62 trigger,63 triggerLabel = "Open drawer",64 image,65 url,66 contained = false,67}: SpringDrawerProps) {68 const reduce = useReducedMotion();69 const titleId = useId();70 const sheetRef = useRef<HTMLDivElement>(null);71 const [mounted, setMounted] = useState(false);72 const [uncontrolled, setUncontrolled] = useState(defaultOpen);73 const open = openControlled ?? uncontrolled;74 const [present, setPresent] = useState(open);75 const [panelOpen, setPanelOpen] = useState(open);76 77 useEffect(() => setMounted(true), []);78 const setOpen = useCallback(79 (next: boolean) => {80 if (openControlled === undefined) setUncontrolled(next);81 onOpenChange?.(next);82 },83 [onOpenChange, openControlled],84 );85 86 const [sheetH, setSheetH] = useState(image ? 420 : 320);87 const y = useMotionValue(0);88 const dragging = useRef(false);89 const startY = useRef(0);90 const startOffset = useRef(0);91 const lastSamples = useRef<{ t: number; y: number }[]>([]);92 93 // Mount / unmount around CSS panel reveal94 useEffect(() => {95 if (open) {96 setPresent(true);97 const id = requestAnimationFrame(() => setPanelOpen(true));98 return () => cancelAnimationFrame(id);99 }100 setPanelOpen(false);101 if (reduce) {102 setPresent(false);103 return;104 }105 const ms = readPanelCloseMs();106 const t = window.setTimeout(() => setPresent(false), ms + 20);107 return () => window.clearTimeout(t);108 }, [open, reduce]);109 110 useEffect(() => {111 if (!present || !sheetRef.current) return;112 const h = sheetRef.current.offsetHeight;113 if (h > 0) setSheetH(h);114 }, [present, image, children, title]);115 116 useEffect(() => {117 if (!open || contained) return;118 const prev = document.body.style.overflow;119 document.body.style.overflow = "hidden";120 return () => {121 document.body.style.overflow = prev;122 };123 }, [open, contained]);124 125 const closeWithVelocity = useCallback(126 (velocityY: number) => {127 if (reduce || Math.abs(velocityY) < 50) {128 setOpen(false);129 return;130 }131 // Fast flick: animate past then close132 animate(y, sheetH + 24, {133 type: "spring",134 bounce: 0,135 duration: 0.32,136 velocity: velocityY,137 }).then(() => {138 y.set(0);139 setOpen(false);140 });141 },142 [reduce, setOpen, sheetH, y],143 );144 145 useEffect(() => {146 if (!open) return;147 const onKey = (e: KeyboardEvent) => {148 if (e.key === "Escape") closeWithVelocity(0);149 };150 window.addEventListener("keydown", onKey);151 return () => window.removeEventListener("keydown", onKey);152 }, [open, closeWithVelocity]);153 154 const openSettle = (velocityY: number) => {155 animate(y, 0, {156 type: "spring",157 bounce: Math.abs(velocityY) > 500 ? 0.1 : 0,158 duration: 0.35,159 velocity: velocityY,160 });161 };162 163 const onPointerDown = (e: React.PointerEvent) => {164 if (reduce) return;165 e.stopPropagation();166 dragging.current = true;167 startY.current = e.clientY;168 startOffset.current = y.get();169 lastSamples.current = [{ t: performance.now(), y: e.clientY }];170 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);171 };172 173 const onPointerMove = (e: React.PointerEvent) => {174 if (!dragging.current) return;175 const delta = e.clientY - startY.current;176 let next = startOffset.current + delta;177 if (next < 0) {178 next = -rubberband(-next, sheetH * 0.4);179 } else if (next > sheetH) {180 next = sheetH + rubberband(next - sheetH, sheetH * 0.35);181 }182 y.set(next);183 lastSamples.current.push({ t: performance.now(), y: e.clientY });184 if (lastSamples.current.length > 5) lastSamples.current.shift();185 };186 187 const velocityFromSamples = () => {188 const s = lastSamples.current;189 if (s.length < 2) return 0;190 const a = s[0];191 const b = s[s.length - 1];192 const dt = (b.t - a.t) / 1000;193 if (dt <= 0) return 0;194 return (b.y - a.y) / dt;195 };196 197 const onPointerUp = () => {198 if (!dragging.current) return;199 dragging.current = false;200 const vy = velocityFromSamples();201 const current = y.get();202 const projected = current + project(vy);203 if (projected > sheetH * 0.28 || vy > 500) {204 closeWithVelocity(vy);205 } else {206 openSettle(vy);207 }208 };209 210 const layer = contained ? "absolute" : "fixed";211 212 const overlay = present ? (213 <>214 <button215 type="button"216 aria-label="Close drawer"217 className={cn(218 layer,219 "inset-0 z-100 bg-black/35 backdrop-blur-[2px] transition-opacity",220 panelOpen ? "opacity-100" : "opacity-0",221 )}222 style={{223 transitionDuration: panelOpen224 ? "var(--panel-open-dur, 400ms)"225 : "var(--panel-close-dur, 350ms)",226 }}227 onClick={() => closeWithVelocity(0)}228 />229 <div230 ref={sheetRef}231 role="dialog"232 aria-modal="true"233 aria-labelledby={titleId}234 className={cn(235 "t-panel-slide",236 layer,237 "inset-x-0 bottom-0 z-110 mx-auto w-full max-w-lg",238 "rounded-t-[calc(var(--radius-lg)+6px)] border border-(--color-rule) border-b-0",239 "bg-(--color-paper)/95 shadow-[0_-16px_48px_rgba(0,0,0,0.16)]",240 "backdrop-blur-xl backdrop-saturate-150",241 )}242 data-open={panelOpen ? "true" : "false"}243 style={244 {245 "--panel-translate-y": `${Math.max(sheetH * 0.45, 100)}px`,246 } as React.CSSProperties247 }248 >249 {/* Drag offset on an inner layer so it doesn't fight .t-panel-slide transform */}250 <motion.div style={{ y }}>251 <div252 className="flex cursor-grab touch-none flex-col active:cursor-grabbing"253 onPointerDown={onPointerDown}254 onPointerMove={onPointerMove}255 onPointerUp={onPointerUp}256 onPointerCancel={onPointerUp}257 >258 <div className="flex justify-center pt-3 pb-2">259 <div className="h-1 w-10 rounded-full bg-(--color-rule)" />260 </div>261 262 {image ? (263 <div className="mx-4 mb-4 overflow-hidden rounded-md border border-(--color-rule) bg-(--color-paper-2) outline outline-1 outline-black/6 dark:outline-white/10">264 <div className="flex items-center gap-2 border-b border-(--color-rule) bg-(--color-paper-2) px-2.5 py-1.5">265 <div className="flex gap-1" aria-hidden>266 <span className="size-1.5 rounded-full bg-(--color-rule)" />267 <span className="size-1.5 rounded-full bg-(--color-rule)" />268 <span className="size-1.5 rounded-full bg-(--color-rule)" />269 </div>270 {url ? (271 <span className="min-w-0 flex-1 truncate font-mono text-[9px] text-muted">272 {url}273 </span>274 ) : null}275 </div>276 {/* eslint-disable-next-line @next/next/no-img-element */}277 <img278 src={image}279 alt=""280 draggable={false}281 className="aspect-16/10 w-full object-cover object-top"282 />283 </div>284 ) : null}285 286 <div className="px-5 pb-1">287 <h2288 id={titleId}289 className="font-(family-name:--font-display) text-[17px] font-medium tracking-[-0.02em] text-balance text-(--color-ink)"290 >291 {title}292 </h2>293 </div>294 </div>295 296 <div className="max-h-[min(40vh,220px)] overflow-y-auto px-5 pt-2 pb-7">297 <div className="text-[13px] leading-relaxed text-pretty text-(--color-ink-2) sm:text-[14px]">298 {children}299 </div>300 </div>301 </motion.div>302 </div>303 </>304 ) : null;305 306 return (307 <div className={cn("relative", className)}>308 {trigger ?? (309 <button310 type="button"311 onClick={() => {312 y.set(0);313 setOpen(true);314 }}315 className={cn(316 "min-h-10 rounded-sm border border-(--color-rule) bg-(--color-paper) px-4",317 "font-(family-name:--font-display) text-[13px] font-medium tracking-tight text-(--color-ink)",318 "shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",319 "active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",320 )}321 >322 {triggerLabel}323 </button>324 )}325 326 {contained327 ? overlay328 : mounted329 ? createPortal(overlay, document.body)330 : null}331 </div>332 );333}
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. |
| 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 element (replaces the default button). |
| 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 — the user can drag again immediately.
- 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.