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.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, {  cloneElement,  isValidElement,  useCallback,  useEffect,  useId,  useLayoutEffect,  useRef,  useState,} from "react";import { createPortal } from "react-dom";import {  animate,  motion,  useMotionValue,  useReducedMotion,  useTransform,  type AnimationPlaybackControlsWithThen,} from "motion/react";import { X } from "lucide-react";import { cn } from "@/lib/utils";
export type SpringDrawerProps = {  open?: boolean;  defaultOpen?: boolean;  onOpenChange?: (open: boolean) => void;  title?: string;  /** Secondary line under the title. */  description?: string;  children: React.ReactNode;  /** Actions pinned to the bottom of the sheet. */  footer?: React.ReactNode;  className?: string;  /** Custom trigger. A single element gets the open handler and ARIA wired in. */  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;/** Parked well below any sheet while closed. */const OFFSCREEN = 4000;const OPEN_SPRING = { type: "spring" as const, bounce: 0, duration: 0.42 };const CLOSE_SPRING = { type: "spring" as const, bounce: 0, duration: 0.32 };
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))  );}
const FOCUSABLE =  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
export function SpringDrawer({  open: openControlled,  defaultOpen = false,  onOpenChange,  title = "Drawer",  description,  children,  footer,  className,  trigger,  triggerLabel = "Open drawer",  image,  url,  contained = false,}: SpringDrawerProps) {  const reduce = useReducedMotion();  const titleId = useId();  const descId = useId();  const dialogId = useId();  const sheetRef = useRef<HTMLDivElement>(null);  const closeRef = useRef<HTMLButtonElement>(null);  const returnFocusRef = useRef<HTMLElement | null>(null);  const [mounted, setMounted] = useState(false);  const [uncontrolled, setUncontrolled] = useState(defaultOpen);  const open = openControlled ?? uncontrolled;  const [present, setPresent] = useState(open);
  useEffect(() => setMounted(true), []);
  const setOpen = useCallback(    (next: boolean) => {      if (openControlled === undefined) setUncontrolled(next);      onOpenChange?.(next);    },    [onOpenChange, openControlled],  );
  // One value drives the sheet for open, drag and close; the scrim reads it  // too, so it dims and clears in step with the finger.  const y = useMotionValue(OFFSCREEN);  const height = useMotionValue(420);  const fade = useMotionValue(reduce ? 0 : 1);  const progress = useTransform(() => {    const h = height.get() || 1;    return Math.min(1, Math.max(0, 1 - y.get() / h));  });  const scrimOpacity = useTransform(() => (reduce ? fade.get() : progress.get()));
  const anim = useRef<AnimationPlaybackControlsWithThen | null>(null);  const run = (a: AnimationPlaybackControlsWithThen) => {    anim.current?.stop();    anim.current = a;    return a;  };  const closeToken = useRef(0);  const releaseVelocity = useRef(0);  // Set when a drag re-grabs a closing sheet: it is already where the finger is.  const skipOpenAnim = useRef(false);
  const close = useCallback(    (velocity = 0) => {      releaseVelocity.current = velocity;      setOpen(false);    },    [setOpen],  );
  useEffect(() => {    if (open) {      closeToken.current++;      setPresent(true);    }  }, [open]);
  // Enter: measure, park just below the fold, spring up. Re-opening while a  // close is in flight retargets from wherever the sheet is.  useLayoutEffect(() => {    if (!present || !open) return;    const el = sheetRef.current;    if (!el) return;    const h = el.offsetHeight;    height.set(h);    if (skipOpenAnim.current) {      skipOpenAnim.current = false;      return;    }    if (reduce) {      y.set(0);      run(animate(fade, 1, { duration: 0.18, ease: "easeOut" }));      return;    }    fade.set(1);    if (y.get() > h + 24) y.set(h + 24);    run(animate(y, 0, OPEN_SPRING));    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [present, open, reduce]);
  // Exit along the same path, carrying any release velocity.  useEffect(() => {    if (open || !present) return;    const token = ++closeToken.current;    const v = releaseVelocity.current;    releaseVelocity.current = 0;    const done = () => {      if (closeToken.current !== token) return;      setPresent(false);      y.set(OFFSCREEN);    };    if (reduce) {      run(animate(fade, 0, { duration: 0.15, ease: "easeIn" })).then(done);    } else {      run(animate(y, height.get() + 24, { ...CLOSE_SPRING, velocity: v })).then(done);    }    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [open]);
  // Keep the measured height current (content, fonts, images, rotation).  useEffect(() => {    if (!present) return;    const el = sheetRef.current;    if (!el) return;    const ro = new ResizeObserver(() => height.set(el.offsetHeight));    ro.observe(el);    return () => ro.disconnect();  }, [present, height]);
  // Focus in on open, back to the trigger after the exit finishes.  useEffect(() => {    if (open && present && mounted) {      if (!returnFocusRef.current && document.activeElement instanceof HTMLElement) {        returnFocusRef.current = document.activeElement;      }      closeRef.current?.focus({ preventScroll: true });    } else if (!open && !present && returnFocusRef.current) {      returnFocusRef.current.focus({ preventScroll: true });      returnFocusRef.current = null;    }  }, [open, present, mounted]);
  // Scroll lock without the page shifting under the scrollbar gutter.  useEffect(() => {    if (!present || contained) return;    const { body, documentElement } = document;    const gutter = window.innerWidth - documentElement.clientWidth;    const prev = { overflow: body.style.overflow, paddingRight: body.style.paddingRight };    body.style.overflow = "hidden";    if (gutter > 0) body.style.paddingRight = `${gutter}px`;    return () => {      body.style.overflow = prev.overflow;      body.style.paddingRight = prev.paddingRight;    };  }, [present, contained]);
  // Escape + focus trap  useEffect(() => {    if (!present || !open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key === "Escape") {        e.preventDefault();        close();        return;      }      if (e.key !== "Tab") return;      const panel = sheetRef.current;      if (!panel) return;      const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(        (el) => !el.closest('[aria-hidden="true"]') && el.getClientRects().length > 0,      );      if (!focusable.length) {        e.preventDefault();        panel.focus();        return;      }      const first = focusable[0];      const last = focusable[focusable.length - 1];      if (e.shiftKey && document.activeElement === first) {        e.preventDefault();        last.focus();      } else if (!e.shiftKey && document.activeElement === last) {        e.preventDefault();        first.focus();      }    };    const containFocus = (event: FocusEvent) => {      const panel = sheetRef.current;      if (panel && !panel.contains(event.target as Node)) {        (panel.querySelector<HTMLElement>(FOCUSABLE) ?? panel).focus();      }    };    window.addEventListener("keydown", onKey);    document.addEventListener("focusin", containFocus);    return () => {      window.removeEventListener("keydown", onKey);      document.removeEventListener("focusin", containFocus);    };  }, [open, present, close]);
  useEffect(() => () => anim.current?.stop(), []);
  /* ── Drag ─────────────────────────────────────────────── */
  const dragging = useRef(false);  const startY = useRef(0);  const startOffset = useRef(0);  const samples = useRef<{ t: number; y: number }[]>([]);
  const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {    if (reduce || !e.isPrimary || e.button !== 0) return;    if (e.target instanceof Element && e.target.closest("[data-drawer-no-drag]")) return;    // Grabbing mid-flight catches the sheet where it is, even mid-close.    anim.current?.stop();    if (!open) {      closeToken.current++;      skipOpenAnim.current = true;      setOpen(true);    }    dragging.current = true;    startY.current = e.clientY;    startOffset.current = y.get();    samples.current = [{ t: performance.now(), y: e.clientY }];    e.currentTarget.setPointerCapture(e.pointerId);  };
  const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {    if (!dragging.current) return;    const h = height.get();    let next = startOffset.current + (e.clientY - startY.current);    if (next < 0) next = -rubberband(-next, h * 0.4);    y.set(next);    samples.current.push({ t: performance.now(), y: e.clientY });    if (samples.current.length > 5) samples.current.shift();  };
  const velocity = () => {    const s = samples.current;    if (s.length < 2) return 0;    const a = s[0];    const b = s[s.length - 1];    const dt = (b.t - a.t) / 1000;    return dt > 0 ? (b.y - a.y) / dt : 0;  };
  const onPointerUp = () => {    if (!dragging.current) return;    dragging.current = false;    const vy = velocity();    const projected = y.get() + project(vy);    if (projected > height.get() * 0.3 || vy > 500) {      close(vy);    } else {      run(        animate(y, 0, {          ...OPEN_SPRING,          bounce: Math.abs(vy) > 500 ? 0.1 : 0,          velocity: vy,        }),      );    }  };
  const dragHandlers = {    onPointerDown,    onPointerMove,    onPointerUp,    onPointerCancel: onPointerUp,  };
  const layer = contained ? "absolute" : "fixed";
  const overlay = present ? (    <>      <motion.div        aria-hidden="true"        className={cn(layer, "inset-0 z-100 bg-black/40 backdrop-blur-[2px]")}        style={{ opacity: scrimOpacity }}        onClick={() => close()}      />      <motion.div        ref={sheetRef}        id={dialogId}        role="dialog"        aria-modal="true"        aria-labelledby={titleId}        aria-describedby={description ? descId : undefined}        tabIndex={-1}        style={{ y, opacity: reduce ? fade : 1 }}        className={cn(          layer,          "inset-x-0 bottom-0 z-110 mx-auto flex max-h-[88dvh] w-full max-w-lg flex-col outline-none",          "rounded-t-[calc(var(--radius-lg)+6px)] border border-b-0 border-(--color-rule)",          "bg-(--color-paper) shadow-[0_-16px_48px_rgba(0,0,0,0.16)]",          "dark:shadow-[0_-16px_48px_rgba(0,0,0,0.5)]",        )}      >        {/* Fills the gap under the sheet while it rubber-bands upward */}        <div          aria-hidden          className="absolute inset-x-[-1px] top-full h-[50vh] border-x border-(--color-rule) bg-(--color-paper)"        />
        <div          {...dragHandlers}          className="shrink-0 cursor-grab touch-none select-none active:cursor-grabbing"        >          <div className="flex justify-center pt-2.5 pb-1.5">            <div className="h-1 w-10 rounded-full bg-(--color-ink)/15" />          </div>
          <div className="flex items-start gap-3 px-5 pt-1 pb-4">            <div className="min-w-0 flex-1">              <h2                id={titleId}                className="font-(family-name:--font-display) text-[17px] font-medium tracking-[-0.02em] text-balance text-(--color-ink)"              >                {title}              </h2>              {description ? (                <div id={descId} className="mt-0.5 text-[13px] text-(--color-neutral)">                  {description}                </div>              ) : null}            </div>            <button              ref={closeRef}              type="button"              aria-label="Close drawer"              data-drawer-no-drag              onClick={() => close()}              className={cn(                "-mt-1 -mr-2 grid size-9 shrink-0 place-items-center rounded-full",                "bg-(--color-paper-2) text-(--color-ink-2)",                "transition-[background-color,color,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",                "hover:bg-(--color-rule) hover:text-(--color-ink) active:scale-[0.94]",                "outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",              )}            >              <X className="size-4" aria-hidden="true" />            </button>          </div>
          {image ? (            <div className="mx-4 mb-1 overflow-hidden rounded-md border border-(--color-rule) bg-(--color-paper-2)">              <div className="flex items-center gap-2.5 border-b border-(--color-rule) px-3 py-1.5">                <div className="flex gap-1" aria-hidden>                  <span className="size-1.5 rounded-full bg-(--color-ink)/15" />                  <span className="size-1.5 rounded-full bg-(--color-ink)/15" />                  <span className="size-1.5 rounded-full bg-(--color-ink)/15" />                </div>                {url ? (                  <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)">                    {url}                  </span>                ) : null}                <span className="w-[22px]" aria-hidden />              </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>
        <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-3 pb-5">          <div className="text-[13px] leading-relaxed text-pretty text-(--color-ink-2) sm:text-[14px]">            {children}          </div>        </div>
        {footer ? (          <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))]">            {footer}          </div>        ) : (          <div aria-hidden className="h-[env(safe-area-inset-bottom)] shrink-0" />        )}      </motion.div>    </>  ) : null;
  const openSheet = () => setOpen(true);  const triggerProps = {    "aria-expanded": open,    "aria-controls": dialogId,    "aria-haspopup": "dialog" as const,  };
  let triggerNode: React.ReactNode;  if (isValidElement<React.HTMLAttributes<HTMLElement>>(trigger)) {    const own = trigger.props.onClick;    triggerNode = cloneElement(trigger, {      ...triggerProps,      onClick: (e: React.MouseEvent<HTMLElement>) => {        own?.(e);        if (!e.defaultPrevented) openSheet();      },    });  } else if (trigger) {    triggerNode = trigger;  } else {    triggerNode = (      <button        {...triggerProps}        type="button"        onClick={openSheet}        className={cn(          "min-h-10 rounded-full 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,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",          "hover:bg-(--color-paper-2) focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:outline-none active:scale-[0.96]",        )}      >        {triggerLabel}      </button>    );  }
  return (    <div className={cn("relative", className)}>      {triggerNode}      {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.ReactNodeYes—Content inside the sheet body.
titlestringNo"Drawer"Accessible dialog title.
descriptionstringNo—Secondary line under the title; wired to aria-describedby.
footerReact.ReactNodeNo—Actions pinned to the bottom of the sheet, above the safe area.
openbooleanNo—Controlled open state.
defaultOpenbooleanNofalseUncontrolled initial open state.
onOpenChange(open: boolean) => voidNo—Called when open state changes.
triggerLabelstringNo"Open drawer"Label for the default trigger button.
triggerReact.ReactNodeNo—Custom trigger. A single element gets the open handler and ARIA attributes wired in.
imagestringNo—Product screenshot rendered in browser chrome under the handle.
urlstringNo—Optional 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 — grabbing the sheet mid-close catches it.
  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.
  5. Lock page scroll without shifting layout: pad the body by the scrollbar gutter.