Hatch
Bottom sheet that closes by physics: pull the machined handle down and release past a distance or velocity threshold to dismiss along your throw, else it springs back. Scrim coupled to sheet position, logarithmic over-pull, critically-damped snap-back, focus-trapped and scroll-locked.
motionfree
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import {
animate,
motion,
useMotionTemplate,
useMotionValue,
useTransform,
type AnimationPlaybackControls,
} from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface HatchProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "title"> {
/** Sheet body — scrolls independently of the drag handle. */
children: React.ReactNode;
/**
* Optional element that opens the sheet on click (uncontrolled convenience).
* Controlled callers can drive `open` directly and omit this.
*/
trigger?: React.ReactNode;
/** Controlled open state. Pair with `onOpenChange`. */
open?: boolean;
/** Initial open state when uncontrolled. @default false */
defaultOpen?: boolean;
/** Fires whenever the sheet opens or closes, controlled or not. */
onOpenChange?: (open: boolean) => void;
/** Fires once the sheet has fully dismissed (after the exit animation). */
onClose?: () => void;
/** Fires when the sheet settles onto a snap point, with that point's index. */
onSnap?: (index: number) => void;
/**
* Rest positions as fractions of the sheet's height, `1` = fully open,
* `0.5` = half-revealed. Sorted internally; the largest is the open seat.
* Arrow keys on the handle nudge between them. @default [1]
*/
snapPoints?: number[];
/** Downward pull past the lowest snap, in px, that dismisses on release. @default 120 */
dismissDistance?: number;
/** Downward release velocity, in px/s, that dismisses regardless of distance. @default 600 */
dismissVelocity?: number;
/** Which surface starts a drag. `"handle"` keeps gesture and body-scroll apart. @default "handle" */
dragSurface?: "handle" | "sheet";
/** Peak scrim opacity when fully open (coupled to sheet position). @default 0.6 */
scrimOpacity?: number;
/** Peak scrim backdrop blur in px when fully open. @default 2 */
scrimBlur?: number;
/** Click the scrim to dismiss. @default true */
dismissOnScrimClick?: boolean;
/** Accessible label for the dialog. @default "Sheet" */
title?: string;
/**
* Portal target. When provided (must be a positioned element) the sheet is
* absolutely contained within it and page scroll is left untouched — handy
* for previews. Defaults to `document.body` (fixed, full-viewport, scroll-locked).
*/
container?: HTMLElement | null;
}
const clamp = (v: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, v));
type Sample = { t: number; y: number };
/**
* Hatch — a bottom sheet that opens on a spring rise and closes by physics:
* grab the machined handle, pull down, and release past a distance or velocity
* threshold to dismiss along your throw — otherwise it springs back to its
* seat. The scrim is coupled to sheet position, not time, so the page behind
* visibly returns as you pull.
*
* Crucible signature: over-pulling past the seat meets logarithmic
* rubber-banding, and every snap-back lands with one critically-damped exhale —
* no wobble chain. Neutral near-black surface, a hairline top edge, and a real
* button for a handle (Enter/Space toggles, arrow keys nudge between snap
* points). Focus-trapped, Escape closes, scroll-locked behind. Controlled or
* uncontrolled. Honors prefers-reduced-motion: open and close cross-fade and
* the drag still works, minus the spring theatrics.
*/
export function Hatch({
children,
trigger,
open: openProp,
defaultOpen = false,
onOpenChange,
onClose,
onSnap,
snapPoints = [1],
dismissDistance = 120,
dismissVelocity = 600,
dragSurface = "handle",
scrimOpacity = 0.6,
scrimBlur = 2,
dismissOnScrimClick = true,
title = "Sheet",
container,
className,
...props
}: HatchProps) {
const reducedMotion = useReducedMotion();
const labelId = React.useId();
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const open = isControlled ? openProp : internalOpen;
// `rendered` outlives `open` so the exit animation can play before unmount.
const [rendered, setRendered] = React.useState(open);
const [mounted, setMounted] = React.useState(false);
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
React.useEffect(() => setMounted(true), []);
// ---- geometry + physics state ----------------------------------------
const sheetRef = React.useRef<HTMLDivElement>(null);
const handleRef = React.useRef<HTMLButtonElement>(null);
const dialogRef = React.useRef<HTMLDivElement>(null);
const restoreFocusRef = React.useRef<HTMLElement | null>(null);
const heightRef = React.useRef(0);
const [snapIndex, setSnapIndex] = React.useState(0);
const snapIndexRef = React.useRef(0);
const y = useMotionValue(9999); // starts off-screen until measured
const fade = useMotionValue(reducedMotion ? 0 : 1); // reduced-motion cross-fade
// Sorted rest offsets in px (0 = fully open at the top).
const offsetsRef = React.useRef<number[]>([0]);
const computeOffsets = React.useCallback(() => {
const h = heightRef.current || 1;
const offs = snapPoints
.map((f) => (1 - clamp(f, 0, 1)) * h)
.sort((a, b) => a - b);
offsetsRef.current = offs.length ? offs : [0];
return offsetsRef.current;
}, [snapPoints]);
// Scrim opacity/blur are a live function of position AND the reduced-motion
// fade, so the page behind returns frame-honestly as you drag the sheet down.
const progress = useTransform<number, number>([y, fade], ([yv, fv]) => {
const h = heightRef.current || 1;
const posOpen = clamp(1 - yv / h, 0, 1);
return Math.min(posOpen, fv);
});
const scrimAlpha = useTransform(progress, (p) => p * scrimOpacity);
const scrimBlurPx = useTransform(progress, (p) => p * scrimBlur);
const scrimFilter = useMotionTemplate`blur(${scrimBlurPx}px)`;
const animRef = React.useRef<AnimationPlaybackControls | null>(null);
const closingRef = React.useRef(false);
const stopAnim = () => {
animRef.current?.stop();
animRef.current = null;
};
// ---- open / close orchestration --------------------------------------
const finishClose = React.useCallback(() => {
closingRef.current = false;
setRendered(false);
onClose?.();
}, [onClose]);
const animateOut = React.useCallback(
(velocity = 0) => {
if (closingRef.current) return;
closingRef.current = true;
stopAnim();
const h = heightRef.current || 1;
if (reducedMotion) {
animRef.current = animate(fade, 0, {
duration: 0.16,
ease: "easeIn",
onComplete: finishClose,
});
} else {
animRef.current = animate(y, h, {
type: "spring",
stiffness: 260,
damping: 34,
mass: 0.9,
velocity,
restDelta: 0.5,
onComplete: finishClose,
});
}
},
[reducedMotion, fade, y, finishClose]
);
// Mount when opened; play the exit when closed while still rendered.
React.useEffect(() => {
if (open) {
if (!rendered) setRendered(true);
} else if (rendered && !closingRef.current) {
animateOut();
}
}, [open, rendered, animateOut]);
// Measure + spring in the moment the sheet is in the DOM.
React.useLayoutEffect(() => {
if (!rendered) return;
const el = sheetRef.current;
if (!el) return;
heightRef.current = el.offsetHeight;
computeOffsets();
snapIndexRef.current = 0;
setSnapIndex(0);
const seat = offsetsRef.current[0];
stopAnim();
if (reducedMotion) {
y.set(seat);
fade.set(0);
animRef.current = animate(fade, 1, { duration: 0.2, ease: "easeOut" });
} else {
fade.set(1);
y.set(heightRef.current);
animRef.current = animate(y, seat, {
type: "spring",
stiffness: 340,
damping: 36,
mass: 0.9,
restDelta: 0.5,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rendered]);
// Keep offsets correct if content reflows while open.
React.useEffect(() => {
if (!rendered) return;
const el = sheetRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
heightRef.current = el.offsetHeight;
computeOffsets();
});
ro.observe(el);
return () => ro.disconnect();
}, [rendered, computeOffsets]);
// Focus management + scroll lock while open.
React.useEffect(() => {
if (!rendered) return;
restoreFocusRef.current = document.activeElement as HTMLElement | null;
const focusTimer = window.setTimeout(() => handleRef.current?.focus(), 0);
const lockScroll = !container;
let prevOverflow = "";
if (lockScroll) {
prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
}
return () => {
window.clearTimeout(focusTimer);
if (lockScroll) document.body.style.overflow = prevOverflow;
restoreFocusRef.current?.focus?.();
};
}, [rendered, container]);
React.useEffect(() => () => stopAnim(), []);
// ---- snapping --------------------------------------------------------
const settleTo = React.useCallback(
(index: number, velocity = 0) => {
const offs = offsetsRef.current;
const i = clamp(index, 0, offs.length - 1);
snapIndexRef.current = i;
setSnapIndex(i);
onSnap?.(i);
stopAnim();
if (reducedMotion) {
animRef.current = animate(y, offs[i], {
duration: 0.18,
ease: "easeOut",
});
} else {
// Critically damped: one exhale, no wobble chain.
animRef.current = animate(y, offs[i], {
type: "spring",
stiffness: 520,
damping: 46,
mass: 0.9,
velocity,
restDelta: 0.4,
});
}
},
[reducedMotion, y, onSnap]
);
const nearestSnapIndex = React.useCallback((value: number) => {
const offs = offsetsRef.current;
let best = 0;
let bestD = Infinity;
offs.forEach((o, i) => {
const d = Math.abs(o - value);
if (d < bestD) {
bestD = d;
best = i;
}
});
return best;
}, []);
// ---- pointer drag ----------------------------------------------------
const dragState = React.useRef<{
active: boolean;
startPointer: number;
startY: number;
samples: Sample[];
} | null>(null);
const onPointerDown = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0 && e.pointerType === "mouse") return;
stopAnim();
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
dragState.current = {
active: true,
startPointer: e.clientY,
startY: y.get(),
samples: [{ t: performance.now(), y: y.get() }],
};
},
[y]
);
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
const st = dragState.current;
if (!st?.active) return;
const delta = e.clientY - st.startPointer;
const seat = offsetsRef.current[0];
let next = st.startY + delta;
// Logarithmic rubber-band when over-pulling above the seat.
if (next < seat) {
const over = seat - next;
const dim = 90;
next = seat - dim * Math.log10(1 + over / dim);
}
y.set(next);
const now = performance.now();
st.samples.push({ t: now, y: next });
if (st.samples.length > 6) st.samples.shift();
},
[y]
);
const endDrag = React.useCallback(
(e: React.PointerEvent) => {
const st = dragState.current;
if (!st?.active) return;
st.active = false;
dragState.current = null;
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
// Velocity from the last two samples (px/s, downward positive).
const s = st.samples;
let velocity = 0;
if (s.length >= 2) {
const a = s[s.length - 2];
const b = s[s.length - 1];
const dt = b.t - a.t;
if (dt > 0) velocity = ((b.y - a.y) / dt) * 1000;
}
const offs = offsetsRef.current;
const lowest = offs[offs.length - 1];
const current = y.get();
const pastDistance = current - lowest > dismissDistance;
const pastVelocity = velocity > dismissVelocity;
if (pastDistance || pastVelocity) {
closingRef.current = true;
stopAnim();
if (reducedMotion) {
animRef.current = animate(fade, 0, {
duration: 0.16,
ease: "easeIn",
onComplete: finishClose,
});
} else {
animRef.current = animate(y, heightRef.current || current + 400, {
type: "spring",
stiffness: 260,
damping: 34,
mass: 0.9,
velocity,
restDelta: 0.5,
onComplete: finishClose,
});
}
setOpen(false);
} else {
settleTo(nearestSnapIndex(current), velocity);
}
},
[
y,
dismissDistance,
dismissVelocity,
reducedMotion,
fade,
finishClose,
setOpen,
settleTo,
nearestSnapIndex,
]
);
// ---- keyboard --------------------------------------------------------
const handleHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(false);
} else if (e.key === "ArrowDown") {
e.preventDefault();
settleTo(snapIndexRef.current + 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
settleTo(snapIndexRef.current - 1);
}
},
[setOpen, settleTo]
);
const handleDialogKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
return;
}
if (e.key !== "Tab") return;
const root = dialogRef.current;
if (!root) return;
const focusables = root.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) {
e.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const activeEl = document.activeElement;
if (e.shiftKey && activeEl === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && activeEl === last) {
e.preventDefault();
first.focus();
}
},
[setOpen]
);
// ---- render ----------------------------------------------------------
const dragHandlers =
dragSurface === "sheet"
? { onPointerDown, onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag }
: {};
const handleDragHandlers =
dragSurface === "handle"
? { onPointerDown, onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag }
: {};
const positionClass = container ? "absolute" : "fixed";
const overlay = (
<div
className={cn(positionClass, "inset-0 z-50", className)}
{...props}
>
{/* Scrim — opacity + blur coupled to sheet position. */}
<motion.button
type="button"
aria-label="Close sheet"
tabIndex={-1}
onClick={dismissOnScrimClick ? () => setOpen(false) : undefined}
className="absolute inset-0 h-full w-full cursor-default bg-black focus:outline-none"
style={{ opacity: scrimAlpha, backdropFilter: scrimFilter }}
/>
{/* Sheet */}
<motion.div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={labelId}
onKeyDown={handleDialogKeyDown}
style={{ y, opacity: reducedMotion ? fade : 1 }}
{...dragHandlers}
className={cn(
"absolute inset-x-0 bottom-0 mx-auto flex max-h-[88%] w-full max-w-lg flex-col",
"rounded-t-2xl border-t border-white/12 bg-neutral-950",
"shadow-[0_-24px_60px_-24px_rgba(0,0,0,0.9)]",
dragSurface === "sheet" && "touch-none"
)}
>
{/* Hairline top specular edge. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px rounded-t-2xl bg-gradient-to-r from-transparent via-white/25 to-transparent"
/>
<div ref={dialogRef} className="flex min-h-0 flex-1 flex-col">
{/* Machined grab handle — a real button. */}
<div className="flex flex-shrink-0 justify-center px-4 pt-2.5 pb-1">
<button
ref={handleRef}
type="button"
aria-label="Sheet handle — press Enter to close, arrow keys to resize"
onKeyDown={handleHandleKeyDown}
{...handleDragHandlers}
className={cn(
"group relative flex h-8 w-full max-w-[64px] items-center justify-center rounded-full",
dragSurface === "handle" ? "touch-none cursor-grab active:cursor-grabbing" : "cursor-default",
"focus-visible:outline-none"
)}
>
<span
className={cn(
"block h-1.5 w-11 rounded-full bg-white/20 transition-colors",
"shadow-[inset_0_1px_0_rgba(255,255,255,0.25),inset_0_-1px_0_rgba(0,0,0,0.6)]",
"group-hover:bg-white/30 group-active:bg-white/40",
"group-focus-visible:bg-white/45 group-focus-visible:ring-2 group-focus-visible:ring-white/40"
)}
/>
</button>
</div>
<h2 id={labelId} className="sr-only">
{title}
</h2>
{/* Independently scrolling body. */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-8 [touch-action:pan-y]">
{children}
</div>
</div>
</motion.div>
</div>
);
return (
<>
{trigger !== undefined && (
<span
onClick={() => setOpen(true)}
className="contents"
data-crucible="hatch-trigger"
>
{trigger}
</span>
)}
{mounted && rendered
? createPortal(overlay, container ?? document.body)
: null}
</>
);
}
Installation
CLI
npx shadcn@latest add @crucible/hatchProps
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | Sheet body — scrolls independently of the drag handle. | |
| trigger | React.ReactNode | Optional element that opens the sheet on click (uncontrolled convenience). Controlled callers can drive open directly and omit this. | |
| open | boolean | Controlled open state. Pair with onOpenChange. | |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | Fires whenever the sheet opens or closes, controlled or not. | |
| onClose | () => void | Fires once the sheet has fully dismissed (after the exit animation). | |
| onSnap | (index: number) => void | Fires when the sheet settles onto a snap point, with that point's index. | |
| snapPoints | number[] | [1] | Rest positions as fractions of the sheet's height, 1 = fully open, 0.5 = half-revealed. Sorted internally; the largest is the open seat. Arrow keys on the handle nudge between them. |
| dismissDistance | number | 120 | Downward pull past the lowest snap, in px, that dismisses on release. |
| dismissVelocity | number | 600 | Downward release velocity, in px/s, that dismisses regardless of distance. |
| dragSurface | "handle" | "sheet" | "handle" | Which surface starts a drag. "handle" keeps gesture and body-scroll apart. |
| scrimOpacity | number | 0.6 | Peak scrim opacity when fully open (coupled to sheet position). |
| scrimBlur | number | 2 | Peak scrim backdrop blur in px when fully open. |
| dismissOnScrimClick | boolean | true | Click the scrim to dismiss. |
| title | string | "Sheet" | Accessible label for the dialog. |
| container | HTMLElement | null | document.body (fixed, full-viewport, scroll-locked) | Portal target. When provided (must be a positioned element) the sheet is absolutely contained within it and page scroll is left untouched — handy for previews. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "title"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.