{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sheet","type":"registry:component","title":"Sheet","description":"A panel that slides in from an edge and can be thrown back: drag it toward its edge and release speed decides whether it closes.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/sheet.tsx","type":"registry:component","content":"// Hairline UI · sheet · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { MODAL_ROOT, SCRIM, useModal } from \"./modal\";\nimport { EASE_OUT, INTERACTION_FOCUS, INTERACTION_PRESS, projectInertia, rubberBand, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * SHEET: a panel that slides in from an edge\n *\n *   open     the page fades back; the panel slides in from its\n *            edge (right, left or bottom) and settles\n *   drag     grab the panel's header (or the bottom sheet's grip)\n *            and pull it toward its edge: it follows 1:1, resists\n *            the other way, and a throw (projected from release\n *            speed) or a pull past a third closes it; otherwise\n *            it springs back\n *   close    Escape, the ✕ or the veil; focus returns to what\n *            opened it\n * ───────────────────────────────────────────────────────── */\n\nexport interface SheetProps {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  trigger?: React.ReactElement<React.HTMLAttributes<HTMLElement>>;\n  side?: \"right\" | \"left\" | \"bottom\";\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  children?: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);\n  footer?: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);\n  /** Width of a side sheet in px. */\n  width?: number;\n  className?: string;\n}\n\nexport function Sheet({\n  open: controlled,\n  defaultOpen = false,\n  onOpenChange,\n  trigger,\n  side = \"right\",\n  title,\n  description,\n  children,\n  footer,\n  width = 400,\n  className = \"\",\n}: SheetProps) {\n  const reduced = useInteractionReducedMotion();\n  const [own, setOwn] = React.useState(defaultOpen);\n  const open = controlled ?? own;\n  const setOpen = (next: boolean) => {\n    if (controlled === undefined) setOwn(next);\n    onOpenChange?.(next);\n  };\n  const close = () => setOpen(false);\n  const { dialog, mounted, shown } = useModal(open, close, 320);\n  const panel = React.useRef<HTMLDivElement>(null);\n  const drag = React.useRef<{ start: number; last: number; t: number; v: number; d: number; size: number; moved: boolean } | null>(null);\n  const titleId = React.useId();\n  const bottom = side === \"bottom\";\n  // +1 when moving toward the edge closes it.\n  const sign = side === \"left\" ? -1 : 1;\n  const hidden = bottom ? \"translate3d(0,100%,0)\" : `translate3d(${sign * 100}%,0,0)`;\n\n  const write = (d: number, animate: boolean) => {\n    const el = panel.current;\n    if (!el) return;\n    el.style.transition = animate ? `transform 380ms ${EASE_OUT}` : \"none\";\n    el.style.transform = d ? (bottom ? `translate3d(0,${d}px,0)` : `translate3d(${sign * d}px,0,0)`) : \"\";\n  };\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    if (e.button !== 0 || (e.target as HTMLElement).closest(\"button, a, input, textarea, select\")) return;\n    const el = panel.current;\n    if (!el) return;\n    const at = bottom ? e.clientY : e.clientX * sign;\n    drag.current = { start: at, last: at, t: performance.now(), v: 0, d: 0, size: bottom ? el.offsetHeight : el.offsetWidth, moved: false };\n    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n  };\n  const onPointerMove = (e: React.PointerEvent) => {\n    const g = drag.current;\n    if (!g) return;\n    const at = bottom ? e.clientY : e.clientX * sign;\n    if (!g.moved && Math.abs(at - g.start) < 6) return;\n    if (!g.moved) {\n      g.moved = true;\n      g.start = at; // no jump when the drag takes over from the slop\n    }\n    const now = performance.now();\n    g.v = (at - g.last) / Math.max(1, now - g.t);\n    g.last = at;\n    g.t = now;\n    const raw = at - g.start;\n    g.d = raw >= 0 ? raw : rubberBand(raw, g.size, 0.3);\n    write(g.d, false);\n  };\n  const onPointerUp = () => {\n    const g = drag.current;\n    drag.current = null;\n    if (!g || !g.moved) return;\n    // Closing leaves the panel where the finger let go; the slide out carries on from there.\n    if (projectInertia(g.d, g.v, 0.12) > g.size / 3) close();\n    else write(0, true);\n  };\n\n  const footerNode = typeof footer === \"function\" ? footer({ close }) : footer;\n  const grip = { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp };\n\n  return (\n    <>\n      {trigger &&\n        React.cloneElement(trigger, {\n          \"aria-haspopup\": \"dialog\",\n          onClick: (e: React.MouseEvent<HTMLElement>) => {\n            trigger.props.onClick?.(e);\n            setOpen(true);\n          },\n        })}\n      {mounted && (\n        <dialog ref={dialog} aria-labelledby={titleId} className={MODAL_ROOT}>\n          <div className={SCRIM} onClick={close} style={{ opacity: shown ? 1 : 0, transition: `opacity ${shown ? 300 : 240}ms ${EASE_OUT}` }} />\n          <div\n            className={`absolute flex ${bottom ? \"inset-x-0 bottom-0 justify-center\" : `inset-y-0 ${side === \"left\" ? \"left-0\" : \"right-0\"}`} pointer-events-none p-2`}\n            style={{ width: bottom ? undefined : `min(${width}px, 100vw)` }}\n          >\n            <div\n              className=\"pointer-events-auto flex size-full\"\n              style={{\n                transform: shown || reduced ? \"none\" : hidden,\n                opacity: reduced ? (shown ? 1 : 0) : 1,\n                transition: reduced ? \"opacity 160ms ease-out\" : `transform ${shown ? 460 : 300}ms ${EASE_OUT}`,\n              }}\n            >\n              <div\n                ref={panel}\n                className={`flex w-full flex-col bg-background shadow-[0_0_0_1px_var(--border)] will-change-transform ${bottom ? \"max-h-[85dvh] max-w-[640px] rounded-[22px]\" : \"h-full rounded-[20px]\"} ${className}`}\n              >\n                {bottom && (\n                  <div {...grip} className=\"flex cursor-grab touch-none justify-center pb-1 pt-2.5 active:cursor-grabbing\">\n                    <span className=\"h-1 w-9 rounded-full bg-border\" />\n                  </div>\n                )}\n                <div {...grip} className={`flex touch-none items-start gap-4 px-5 ${bottom ? \"pt-2\" : \"cursor-grab pt-5 active:cursor-grabbing\"} pb-3`}>\n                  <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                    <h2 id={titleId} className=\"text-[16px] font-semibold tracking-[-0.01em] text-foreground\">\n                      {title}\n                    </h2>\n                    {description && <p className=\"text-[13.5px] leading-relaxed text-muted-foreground\">{description}</p>}\n                  </div>\n                  <button\n                    type=\"button\"\n                    aria-label=\"Close\"\n                    onClick={close}\n                    className={`-mr-1.5 flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}\n                  >\n                    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n                      <path d=\"M4 4l8 8M12 4l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n                    </svg>\n                  </button>\n                </div>\n                <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-4 text-[13.5px] leading-relaxed text-foreground\">\n                  {typeof children === \"function\" ? children({ close }) : children}\n                </div>\n                {footerNode !== undefined && <div className=\"flex flex-col-reverse gap-2 border-t border-border px-5 py-4 sm:flex-row sm:justify-end\">{footerNode}</div>}\n              </div>\n            </div>\n          </div>\n        </dialog>\n      )}\n    </>\n  );\n}\n\nexport default Sheet;\n"},{"path":"registry/modal.ts","type":"registry:component","content":"// Hairline UI · sheet · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * MODAL: the native <dialog> under Dialog and Sheet\n *\n *   open     showModal() (focus trap, inert page, top layer) and\n *            the next frame turns `shown` on for the entry motion\n *   close    Escape and the scrim ask to close; the surface plays\n *            its exit, then dialog.close() and focus goes back to\n *            whatever opened it\n *   scroll   wheel and touch scrolling outside a scrollable part\n *            of the panel are stopped at the dialog, so the page\n *            behind never moves (the page's own styles are never\n *            touched: no layout shift, no restyle of <html>)\n * ───────────────────────────────────────────────────────── */\n\nexport function useModal(open: boolean, onClose: () => void, exit = 220) {\n  const dialog = React.useRef<HTMLDialogElement>(null);\n  const [mounted, setMounted] = React.useState(open);\n  const [shown, setShown] = React.useState(false);\n  const opener = React.useRef<HTMLElement | null>(null);\n  const close = React.useRef(onClose);\n  React.useEffect(() => {\n    close.current = onClose;\n  });\n  if (open && !mounted) setMounted(true);\n\n  React.useEffect(() => {\n    const el = dialog.current;\n    if (!el) return;\n    if (open) {\n      opener.current = document.activeElement as HTMLElement | null;\n      if (!el.open) el.showModal();\n      let inner = 0;\n      const outer = requestAnimationFrame(() => {\n        inner = requestAnimationFrame(() => setShown(true));\n      });\n      return () => {\n        cancelAnimationFrame(outer);\n        cancelAnimationFrame(inner);\n      };\n    }\n    const timer = window.setTimeout(() => {\n      if (el.open) el.close();\n      setMounted(false);\n      setShown(false);\n      opener.current?.focus?.({ preventScroll: true });\n    }, exit);\n    return () => window.clearTimeout(timer);\n  }, [open, mounted, exit]);\n\n  React.useEffect(() => {\n    const el = dialog.current;\n    if (!el || !mounted) return;\n    const onCancel = (e: Event) => {\n      e.preventDefault();\n      close.current();\n    };\n    // Stop scrolling from leaking to the page unless it happens inside something that scrolls.\n    const guard = (e: Event) => {\n      let node = e.target as HTMLElement | null;\n      while (node && node !== el) {\n        const style = getComputedStyle(node);\n        if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight) return;\n        node = node.parentElement;\n      }\n      e.preventDefault();\n    };\n    el.addEventListener(\"cancel\", onCancel);\n    el.addEventListener(\"wheel\", guard, { passive: false });\n    el.addEventListener(\"touchmove\", guard, { passive: false });\n    return () => {\n      el.removeEventListener(\"cancel\", onCancel);\n      el.removeEventListener(\"wheel\", guard);\n      el.removeEventListener(\"touchmove\", guard);\n    };\n  }, [mounted]);\n\n  return { dialog, mounted: mounted || open, shown: open && shown };\n}\n\n/** Class for the full-window <dialog> both surfaces sit in. */\nexport const MODAL_ROOT =\n  \"fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden bg-transparent p-0 text-foreground outline-none backdrop:bg-transparent\";\n\n/** The dim behind the surface: the page fades back under a veil of the background colour, no blur. */\nexport const SCRIM = \"absolute inset-0 bg-[color-mix(in_oklab,var(--background)_62%,transparent)]\";\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/sheet. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}