# Hatch — Animated UI Component for React

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

- Page: https://crucibleui.com/components/utility/hatch
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/hatch
```

Registry JSON: https://crucibleui.com/r/hatch.json

Import from `@/components/crucible/hatch`.

## Props (`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: `Omit<React.ComponentPropsWithoutRef<"div">, "title">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Hatch } from "@/components/crucible/hatch";

export default function HatchDemo() {
  const [open, setOpen] = React.useState(false);
  const stageRef = React.useRef<HTMLDivElement>(null);
  const [container, setContainer] = React.useState<HTMLElement | null>(null);

  React.useEffect(() => setContainer(stageRef.current), []);

  return (
    <div
      ref={stageRef}
      className="relative isolate flex h-full min-h-[360px] w-full flex-col items-center justify-center overflow-hidden rounded-xl bg-neutral-950"
    >
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_0%,rgba(255,255,255,0.04),transparent_70%)]"
      />

      <button
        type="button"
        onClick={() => setOpen(true)}
        className="relative z-10 rounded-lg border border-white/15 bg-white/[0.06] px-5 py-2.5 text-sm font-medium text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.1)] transition-colors hover:bg-white/[0.1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
      >
        Open sheet
      </button>
      <p className="relative z-10 mt-4 text-sm text-neutral-500">
        Drag the handle down to close.
      </p>

      <Hatch
        open={open}
        onOpenChange={setOpen}
        container={container}
        title="Details"
      >
        <div className="mx-auto w-full max-w-md">
          <h3 className="text-base font-semibold text-neutral-100">
            Shipping details
          </h3>
          <p className="mt-1 text-sm text-neutral-400">
            Grab the handle above and pull down to dismiss — release past the
            threshold and the sheet throws itself closed, otherwise it springs
            back to its seat.
          </p>

          <div className="mt-5 space-y-3">
            {[
              ["Standard", "5–7 business days", "Free"],
              ["Express", "2–3 business days", "$12"],
              ["Overnight", "Next business day", "$28"],
            ].map(([name, eta, price]) => (
              <label
                key={name}
                className="flex cursor-pointer items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-4 py-3 transition-colors hover:border-white/20"
              >
                <span className="flex items-center gap-3">
                  <input
                    type="radio"
                    name="shipping"
                    className="size-4 accent-neutral-300"
                    defaultChecked={name === "Standard"}
                  />
                  <span>
                    <span className="block text-sm font-medium text-neutral-100">
                      {name}
                    </span>
                    <span className="block text-xs text-neutral-500">{eta}</span>
                  </span>
                </span>
                <span className="text-sm text-neutral-300">{price}</span>
              </label>
            ))}
          </div>

          <p className="mt-5 text-xs leading-relaxed text-neutral-500">
            The body scrolls on its own, so the gesture and the scroll never
            fight. Keyboard: the handle is a real button — Enter closes, arrow
            keys resize. Escape closes from anywhere.
          </p>

          <button
            type="button"
            onClick={() => setOpen(false)}
            className="mt-5 w-full rounded-lg bg-neutral-100 px-4 py-2.5 text-sm font-medium text-neutral-900 transition-colors hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
          >
            Confirm
          </button>
        </div>
      </Hatch>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`hatch.tsx`)

```tsx
"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 "@/hooks/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}
    </>
  );
}
```
