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.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/spring-drawer.tsx
tsx
"use client";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";import { createPortal } from "react-dom";import {  AnimatePresence,  motion,  useMotionValue,  animate,  useReducedMotion,} from "motion/react";import { cn } from "@/lib/utils";
export type SpringDrawerProps = {  open?: boolean;  defaultOpen?: boolean;  onOpenChange?: (open: boolean) => void;  title?: string;  children: React.ReactNode;  className?: string;  trigger?: React.ReactNode;  triggerLabel?: string;  /** Product screenshot shown as a page screen */  image?: string;  /** Optional URL shown in the browser chrome */  url?: string;  /**   * @deprecated Prefer page-level (fixed) drawers.   * Contained mode clips into a local frame — avoid for demos.   */  contained?: boolean;};
const DECEL = 0.998;const EASE_OUT = [0.23, 1, 0.32, 1] as const;
function project(velocity: number, decelerationRate = DECEL) {  return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);}
function rubberband(overshoot: number, dimension: number, constant = 0.55) {  return (    (overshoot * dimension * constant) /    (dimension + constant * Math.abs(overshoot))  );}
export function SpringDrawer({  open: openControlled,  defaultOpen = false,  onOpenChange,  title = "Drawer",  children,  className,  trigger,  triggerLabel = "Open drawer",  image,  url,  contained = false,}: SpringDrawerProps) {  const reduce = useReducedMotion();  const titleId = useId();  const sheetRef = useRef<HTMLDivElement>(null);  const [mounted, setMounted] = useState(false);  const [uncontrolled, setUncontrolled] = useState(defaultOpen);  const open = openControlled ?? uncontrolled;
  useEffect(() => setMounted(true), []);  const setOpen = useCallback(    (next: boolean) => {      if (openControlled === undefined) setUncontrolled(next);      onOpenChange?.(next);    },    [onOpenChange, openControlled],  );
  const [sheetH, setSheetH] = useState(image ? 420 : 320);  const y = useMotionValue(sheetH);  const dragging = useRef(false);  const startY = useRef(0);  const startOffset = useRef(0);  const lastSamples = useRef<{ t: number; y: number }[]>([]);
  useEffect(() => {    if (!open || !sheetRef.current) return;    const h = sheetRef.current.offsetHeight;    if (h > 0) setSheetH(h);  }, [open, image, children, title]);
  useEffect(() => {    if (!open) return;    if (reduce) {      y.set(0);      return;    }    y.set(sheetH);    animate(y, 0, { type: "spring", bounce: 0, duration: 0.4 });  }, [open, reduce, y, sheetH]);
  useEffect(() => {    if (!open || contained) return;    const prev = document.body.style.overflow;    document.body.style.overflow = "hidden";    return () => {      document.body.style.overflow = prev;    };  }, [open, contained]);
  const closeWithVelocity = useCallback(    (velocityY: number) => {      if (reduce) {        setOpen(false);        return;      }      animate(y, sheetH + 24, {        type: "spring",        bounce: 0,        duration: 0.32,        velocity: velocityY,      }).then(() => setOpen(false));    },    [reduce, setOpen, sheetH, y],  );
  useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key === "Escape") closeWithVelocity(0);    };    window.addEventListener("keydown", onKey);    return () => window.removeEventListener("keydown", onKey);  }, [open, closeWithVelocity]);
  const openSettle = (velocityY: number) => {    animate(y, 0, {      type: "spring",      bounce: Math.abs(velocityY) > 500 ? 0.1 : 0,      duration: 0.35,      velocity: velocityY,    });  };
  const onPointerDown = (e: React.PointerEvent) => {    if (reduce) return;    e.stopPropagation();    dragging.current = true;    startY.current = e.clientY;    startOffset.current = y.get();    lastSamples.current = [{ t: performance.now(), y: e.clientY }];    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);  };
  const onPointerMove = (e: React.PointerEvent) => {    if (!dragging.current) return;    const delta = e.clientY - startY.current;    let next = startOffset.current + delta;    if (next < 0) {      next = -rubberband(-next, sheetH * 0.4);    } else if (next > sheetH) {      next = sheetH + rubberband(next - sheetH, sheetH * 0.35);    }    y.set(next);    lastSamples.current.push({ t: performance.now(), y: e.clientY });    if (lastSamples.current.length > 5) lastSamples.current.shift();  };
  const velocityFromSamples = () => {    const s = lastSamples.current;    if (s.length < 2) return 0;    const a = s[0];    const b = s[s.length - 1];    const dt = (b.t - a.t) / 1000;    if (dt <= 0) return 0;    return (b.y - a.y) / dt;  };
  const onPointerUp = () => {    if (!dragging.current) return;    dragging.current = false;    const vy = velocityFromSamples();    const current = y.get();    const projected = current + project(vy);    if (projected > sheetH * 0.28 || vy > 500) {      closeWithVelocity(vy);    } else {      openSettle(vy);    }  };
  const layer = contained ? "absolute" : "fixed";
  const overlay = (    <AnimatePresence initial={false}>      {open ? (        <>          <motion.button            type="button"            aria-label="Close drawer"            className={cn(              layer,              "inset-0 z-100 bg-black/35 backdrop-blur-[2px]",            )}            initial={{ opacity: 0 }}            animate={{ opacity: 1 }}            exit={{ opacity: 0 }}            transition={              reduce                ? { duration: 0.12 }                : { duration: 0.22, ease: EASE_OUT }            }            onClick={() => closeWithVelocity(0)}          />          <motion.div            ref={sheetRef}            role="dialog"            aria-modal="true"            aria-labelledby={titleId}            className={cn(              layer,              "inset-x-0 bottom-0 z-110 mx-auto w-full max-w-lg",              "rounded-t-[calc(var(--radius-lg)+6px)] border border-(--color-rule) border-b-0",              "bg-(--color-paper)/95 shadow-[0_-16px_48px_rgba(0,0,0,0.16)]",              "backdrop-blur-xl backdrop-saturate-150",            )}            style={reduce ? undefined : { y }}            initial={reduce ? { opacity: 0, y: 12 } : undefined}            animate={reduce ? { opacity: 1, y: 0 } : undefined}            exit={              reduce                ? {                    opacity: 0,                    y: 8,                    transition: { duration: 0.15, ease: EASE_OUT },                  }                : undefined            }          >            <div              className="flex cursor-grab touch-none flex-col active:cursor-grabbing"              onPointerDown={onPointerDown}              onPointerMove={onPointerMove}              onPointerUp={onPointerUp}              onPointerCancel={onPointerUp}            >              <div className="flex justify-center pt-3 pb-2">                <div className="h-1 w-10 rounded-full bg-(--color-rule)" />              </div>
              {image ? (                <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">                  <div className="flex items-center gap-2 border-b border-(--color-rule) bg-(--color-paper-2) px-2.5 py-1.5">                    <div className="flex gap-1" aria-hidden>                      <span className="size-1.5 rounded-full bg-(--color-rule)" />                      <span className="size-1.5 rounded-full bg-(--color-rule)" />                      <span className="size-1.5 rounded-full bg-(--color-rule)" />                    </div>                    {url ? (                      <span className="min-w-0 flex-1 truncate font-mono text-[9px] text-muted">                        {url}                      </span>                    ) : null}                  </div>                  {/* eslint-disable-next-line @next/next/no-img-element */}                  <img                    src={image}                    alt=""                    draggable={false}                    className="aspect-16/10 w-full object-cover object-top"                  />                </div>              ) : null}
              <div className="px-5 pb-1">                <h2                  id={titleId}                  className="font-(family-name:--font-display) text-[17px] font-medium tracking-[-0.02em] text-balance text-(--color-ink)"                >                  {title}                </h2>              </div>            </div>
            <div className="max-h-[min(40vh,220px)] overflow-y-auto px-5 pt-2 pb-7">              <div className="text-[13px] leading-relaxed text-pretty text-(--color-ink-2) sm:text-[14px]">                {children}              </div>            </div>          </motion.div>        </>      ) : null}    </AnimatePresence>  );
  return (    <div className={cn("relative", className)}>      {trigger ?? (        <button          type="button"          onClick={() => {            y.set(reduce ? 0 : sheetH);            setOpen(true);          }}          className={cn(            "min-h-10 rounded-sm border border-(--color-rule) bg-(--color-paper) px-4",            "font-(family-name:--font-display) text-[13px] font-medium tracking-tight text-(--color-ink)",            "shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",            "active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",          )}        >          {triggerLabel}        </button>      )}
      {contained        ? overlay        : mounted          ? createPortal(overlay, document.body)          : null}    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { SpringDrawer } from "@/components/ui/spring-drawer";
<SpringDrawer title="Notes" triggerLabel="Open">  <p>Sheet content</p></SpringDrawer>

Props

PropTypeRequiredDefaultDescription
childrenReact.ReactNodeYesContent inside the sheet body.
titlestringNo"Drawer"Accessible dialog title.
openbooleanNoControlled open state.
defaultOpenbooleanNofalseUncontrolled initial open state.
onOpenChange(open: boolean) => voidNoCalled when open state changes.
triggerLabelstringNo"Open drawer"Label for the default trigger button.
triggerReact.ReactNodeNoCustom trigger element (replaces the default button).
imagestringNoProduct screenshot rendered in browser chrome under the handle.
urlstringNoOptional URL shown in the browser chrome.
containedbooleanNofalseDeprecated — prefer page-level fixed sheets. Local clipping for rare embeds.

Best Practices

  1. Never lock input during the close spring — the user can drag again immediately.
  2. Use a dimming scrim for modal focus; Escape and scrim share the dismiss path.
  3. Hand off release velocity into the spring so drag and settle feel continuous.
  4. Under reduced motion, crossfade opacity instead of translating the sheet.