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
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | ReactElement | The control that opens the sheet. | |
| title | string | The accessible title. | |
| description | string | One 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.