Skip to content
goodtabs UI
BrowseBrowse components

Spring sheet

A dialog sheet that hands your release velocity to a spring and projects its rest.

npx shadcn@latest add https://ui.goodtabs.co/r/spring-sheet.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/spring-sheet

An empty sheet that prints the projected rest after each release.

With content

Buttons inside the sheet, reachable by keyboard.

Usage

import { SpringSheet } from "@/components/ui/spring-sheet"

<SpringSheet trigger={<Button>Open</Button>} title="Title" description="One line.">
  Body
</SpringSheet>

Props

PropTypeDefaultDescription
triggerReactElementThe control that opens the sheet.
titlestringThe accessible title.
descriptionstringOne line under the title, also the accessible description.

Source

spring-sheet/spring-sheet.tsx
"use client";

import * as React from "react";
import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion } from "motion/react";
import { Dialog as DialogPrimitive } from "radix-ui";

type SpringSheetProps = {
  /** The control that opens the sheet. Passed to the trigger as its child element. */
  trigger: React.ReactElement;
  /** The sheet title. */
  title: string;
  /** One line under the title. */
  description: string;
  /** The sheet body. */
  children?: React.ReactNode;
};

/** Per-millisecond velocity retention for a flick, 0.998 is iOS normal. */
const RATE = 0.998;
/** Rubber band stiffness. */
const BAND = 0.55;
/** Pixels of travel per pixel per millisecond of velocity: rate / (1 - rate). */
const PROJECT = RATE / (1 - RATE);

type Readout = { v: number; rest: number; closed: boolean };

/**
 * A bottom sheet that hands the pointer's velocity to a spring. On release the rest point is
 * projected as y + v × 499, and past half the height the sheet closes. Dragging up meets a
 * rubber band. Escape, the down arrow on the handle, and the close button all close it.
 * Under reduced motion it opens and closes instantly.
 */
export function SpringSheet({ trigger, title, description, children }: SpringSheetProps) {
  const reduce = useReducedMotion();
  const [open, setOpen] = React.useState(false);
  const [readout, setReadout] = React.useState<Readout | null>(null);
  const panel = React.useRef<HTMLDivElement>(null);
  const y = useMotionValue(0);
  const drag = React.useRef<{ y0: number; t: number; last: number; v: number } | null>(null);

  const height = () => panel.current?.offsetHeight ?? 400;

  const onPointerDown = (e: React.PointerEvent) => {
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { y0: e.clientY - y.get(), t: e.timeStamp, last: e.clientY, v: 0 };
  };

  const onPointerMove = (e: React.PointerEvent) => {
    const d = drag.current;
    if (!d) return;
    const dy = e.clientY - d.y0;
    const dt = Math.max(1, e.timeStamp - d.t);
    d.v = (e.clientY - d.last) / dt;
    d.t = e.timeStamp;
    d.last = e.clientY;
    const D = height();
    y.set(dy >= 0 ? dy : -(1 - 1 / ((-dy * BAND) / D + 1)) * D);
  };

  const onPointerUp = (e: React.PointerEvent) => {
    const d = drag.current;
    drag.current = null;
    if (!d) return;
    // A pause before release means the hand had stopped.
    if (e.timeStamp - d.t > 80) d.v = 0;
    const rest = y.get() + d.v * PROJECT;
    const closed = rest > height() / 2;
    setReadout({ v: d.v, rest, closed });
    if (closed) setOpen(false);
    else if (reduce) y.set(0);
    else animate(y, 0, { type: "spring", stiffness: 300, damping: 30, velocity: d.v * 1000 });
  };

  const t = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 300, damping: 30 };

  return (
    <DialogPrimitive.Root
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (next) {
          y.set(0);
          setReadout(null);
        }
      }}
    >
      <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>
      <AnimatePresence>
        {open ? (
          <DialogPrimitive.Portal forceMount>
            <DialogPrimitive.Overlay asChild forceMount>
              <motion.div
                className="fixed inset-0 z-50 bg-black/50"
                initial={{ opacity: reduce ? 1 : 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: reduce ? 1 : 0 }}
                transition={{ duration: reduce ? 0 : 0.15 }}
              />
            </DialogPrimitive.Overlay>
            <DialogPrimitive.Content asChild forceMount>
              <motion.div
                className="fixed inset-x-0 bottom-0 z-50 mx-auto w-full max-w-md outline-none"
                initial={{ y: reduce ? 0 : "100%" }}
                animate={{ y: 0 }}
                exit={{ y: reduce ? 0 : "100%" }}
                transition={t}
              >
                <motion.div
                  ref={panel}
                  style={{ y }}
                  className="rounded-t-xl border border-b-0 bg-popover text-popover-foreground shadow-lg"
                >
                  <button
                    type="button"
                    aria-label="Close sheet. Drag down or press the down arrow."
                    onPointerDown={onPointerDown}
                    onPointerMove={onPointerMove}
                    onPointerUp={onPointerUp}
                    onPointerCancel={onPointerUp}
                    onClick={(e) => {
                      if (e.detail === 0) setOpen(false);
                    }}
                    onKeyDown={(e) => {
                      if (e.key === "ArrowDown") {
                        e.preventDefault();
                        setOpen(false);
                      }
                    }}
                    className="group flex h-8 w-full cursor-grab touch-none items-center justify-center rounded-t-xl outline-none active:cursor-grabbing"
                  >
                    <span className="h-1 w-9 rounded-full bg-foreground/20 group-hover:bg-foreground/35 group-focus-visible:bg-foreground/60 group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-4 group-focus-visible:ring-offset-popover" />
                  </button>
                  <div className="flex flex-col gap-1 px-4 pb-3">
                    <DialogPrimitive.Title className="text-base font-medium">{title}</DialogPrimitive.Title>
                    <DialogPrimitive.Description className="text-sm text-muted-foreground">
                      {description}
                    </DialogPrimitive.Description>
                  </div>
                  <div className="px-4 pb-6">
                    {children}
                    <p aria-live="polite" className="mt-3 min-h-4 font-mono text-xs text-muted-foreground tabular-nums">
                      {readout
                        ? `rest = y + ${readout.v.toFixed(2)} × ${Math.round(PROJECT)} = ${Math.round(readout.rest)}px, ${readout.closed ? "closed" : "kept"}`
                        : "Release the handle to see the projected rest."}
                    </p>
                  </div>
                </motion.div>
              </motion.div>
            </DialogPrimitive.Content>
          </DialogPrimitive.Portal>
        ) : null}
      </AnimatePresence>
    </DialogPrimitive.Root>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/spring-sheet/prompt.md, or the Copy AI prompt button above.

When to use

  • Mobile style sheets that should respond to a flick
  • Teaching how velocity, projection and rubber banding fit together

When not to use

  • Long scrolling content: the handle drag conflicts with a scrolling body
  • Desktop only flows where a centred dialog fits better

Agent notes

  • Built on the Radix Dialog primitive, so focus is trapped, Escape closes, and focus returns to the trigger
  • Keyboard path: Tab to the handle and press the down arrow, Enter or Space to close
  • Under reduced motion it opens and closes instantly and a released drag snaps back with no spring
  • The readout line is aria-live and is meant for teaching, delete it in production

The maths

  • Projected rest = y + v × rate / (1 - rate) with rate 0.998, so v × 499 pixels.
  • Rubber band offset = (1 - 1 / (d × 0.55 / D + 1)) × D, where D is the sheet height.
  • The sheet closes when the projected rest passes D / 2, otherwise a spring (stiffness 300, damping 30, ζ 0.87) takes the velocity back to 0.