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