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