{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drawer",
  "type": "registry:ui",
  "title": "Drawer",
  "description": "Includes focus containment, drag dismissal, and interruptible spring motion.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "overlays-surfaces"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/drawer/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Drawer, backdrop, and focus enter and leave as one layer",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Open side panel for a focused task",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/drawer.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/drawer.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  animate,\n  motion,\n  useDragControls,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\n\nconst DISCLOSE = {\n  type: \"spring\",\n  stiffness: 150,\n  damping: 27,\n  mass: 1,\n} as const;\n\nconst FOCUSABLE =\n  'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\ntype Inertable = HTMLElement & { inert?: boolean };\n\ntype DragInfo = {\n  offset: { x: number; y: number };\n  velocity: { x: number; y: number };\n};\n\nexport type DrawerSide = \"left\" | \"right\";\n\nexport type UseDrawerOptions = {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  side?: DrawerSide;\n  width?: number;\n  dismissRatio?: number;\n  modal?: boolean;\n};\n\nexport function useDrawer({\n  open: controlled,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"right\",\n  width = 320,\n  dismissRatio = 0.38,\n  modal = true,\n}: UseDrawerOptions = {}) {\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const [dragging, setDragging] = useState(false);\n\n  const open = controlled ?? uncontrolled;\n  const sign = side === \"right\" ? 1 : -1;\n  const away = sign * (width + 24);\n\n  const x = useMotionValue(open ? 0 : away);\n  const veil = useTransform(x, (v) => 1 - Math.min(1, Math.abs(v) / width));\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const returnTo = useRef<HTMLElement | null>(null);\n  const anim = useRef<{ stop: () => void } | null>(null);\n  const live = useRef(open);\n  live.current = open;\n\n  const changed = useRef(onOpenChange);\n  changed.current = onOpenChange;\n\n  const reduced = useReducedMotion();\n  const controls = useDragControls();\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (controlled === undefined) setUncontrolled(next);\n      changed.current?.(next);\n    },\n    [controlled],\n  );\n\n  const close = useCallback(() => setOpen(false), [setOpen]);\n\n  const glide = useCallback(\n    (to: number) => {\n      anim.current?.stop();\n      anim.current = animate(x, to, reduced ? { duration: 0 } : DISCLOSE);\n    },\n    [x, reduced],\n  );\n\n  useEffect(() => {\n    glide(open ? 0 : away);\n    return () => anim.current?.stop();\n  }, [open, away, glide]);\n\n  useEffect(() => {\n    const panel = panelRef.current as Inertable | null;\n    if (!panel) return;\n    panel.inert = !open;\n    return () => {\n      panel.inert = false;\n    };\n  }, [open]);\n\n  useEffect(() => {\n    if (open) {\n      const active = document.activeElement;\n      returnTo.current = active instanceof HTMLElement ? active : null;\n      const panel = panelRef.current;\n      if (!panel) return;\n      const first = panel.querySelector<HTMLElement>(FOCUSABLE);\n      (first ?? panel).focus({ preventScroll: true });\n      return;\n    }\n    const target = returnTo.current;\n    returnTo.current = null;\n    if (target && target.isConnected) target.focus({ preventScroll: true });\n  }, [open]);\n\n  useEffect(() => {\n    if (!modal || !open) return;\n    const root = document.documentElement;\n    const overflow = root.style.overflow;\n    const padding = root.style.paddingRight;\n    const gutter = window.innerWidth - root.clientWidth;\n\n    root.style.overflow = \"hidden\";\n    if (gutter > 0) root.style.paddingRight = `${gutter}px`;\n\n    return () => {\n      root.style.overflow = overflow;\n      root.style.paddingRight = padding;\n    };\n  }, [modal, open]);\n\n  useEffect(() => {\n    const shell = rootRef.current;\n    if (!modal || !open || !shell) return;\n    const muted: Inertable[] = [];\n\n    for (const node of Array.from(document.body.children)) {\n      if (!(node instanceof HTMLElement) || node.contains(shell)) continue;\n      const el = node as Inertable;\n      if (el.inert) continue;\n      el.inert = true;\n      muted.push(el);\n    }\n\n    return () => {\n      for (const el of muted) el.inert = false;\n    };\n  }, [modal, open]);\n\n  const onKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      const panel = panelRef.current;\n      if (!panel) return;\n\n      if (event.key === \"Escape\") {\n        event.stopPropagation();\n        close();\n        return;\n      }\n      if (event.key !== \"Tab\") return;\n\n      const nodes = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE));\n      if (nodes.length === 0) {\n        event.preventDefault();\n        panel.focus({ preventScroll: true });\n        return;\n      }\n\n      const first = nodes[0];\n      const last = nodes[nodes.length - 1];\n      const active = document.activeElement;\n\n      if (event.shiftKey && (active === first || active === panel)) {\n        event.preventDefault();\n        last.focus();\n      } else if (!event.shiftKey && active === last) {\n        event.preventDefault();\n        first.focus();\n      }\n    },\n    [close],\n  );\n\n  const startDrag = useCallback(\n    (event: React.PointerEvent) => {\n      if (!live.current) return;\n      controls.start(event);\n    },\n    [controls],\n  );\n\n  const onDragStart = useCallback(() => setDragging(true), []);\n\n  const onDragEnd = useCallback(\n    (_event: MouseEvent | TouchEvent | PointerEvent, info: DragInfo) => {\n      setDragging(false);\n      const travel = sign * info.offset.x;\n      const speed = sign * info.velocity.x;\n      if (travel > width * dismissRatio || speed > 520) {\n        close();\n        return;\n      }\n      glide(0);\n    },\n    [sign, width, dismissRatio, glide, close],\n  );\n\n  const panelProps = {\n    tabIndex: -1,\n    role: \"dialog\" as const,\n    \"aria-modal\": modal,\n    onKeyDown,\n    drag: \"x\" as const,\n    dragControls: controls,\n    dragListener: false,\n    dragMomentum: false,\n    dragConstraints: { left: 0, right: 0 },\n    dragElastic:\n      side === \"right\"\n        ? { top: 0, bottom: 0, left: 0, right: 1 }\n        : { top: 0, bottom: 0, left: 1, right: 0 },\n    onDragStart,\n    onDragEnd,\n  };\n\n  return {\n    open,\n    side,\n    width,\n    dragging,\n    x,\n    veil,\n    setOpen,\n    close,\n    rootRef,\n    panelRef,\n    panelProps,\n    gripProps: { onPointerDown: startDrag },\n  };\n}\n\nexport type UseDrawerResult = ReturnType<typeof useDrawer>;\n\nconst CLOSE_ICON = (\n  <svg width=\"13\" height=\"13\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <line\n      x1=\"200\"\n      y1=\"56\"\n      x2=\"56\"\n      y2=\"200\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n    />\n    <line\n      x1=\"200\"\n      y1=\"200\"\n      x2=\"56\"\n      y2=\"56\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n    />\n  </svg>\n);\n\nexport type DrawerProps = {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  title: string;\n  children: React.ReactNode;\n  description?: string;\n  footer?: React.ReactNode;\n  side?: DrawerSide;\n  width?: number;\n  container?: \"viewport\" | \"parent\";\n  closeLabel?: string;\n  hint?: string;\n  dismissOnScrimClick?: boolean;\n  className?: string;\n};\n\nexport function Drawer({\n  open,\n  onOpenChange,\n  title,\n  children,\n  description,\n  footer,\n  side = \"right\",\n  width = 320,\n  container = \"viewport\",\n  closeLabel = \"Close panel\",\n  hint = \"Press Escape to close this panel, or drag its handle toward the edge.\",\n  dismissOnScrimClick = true,\n  className = \"\",\n}: DrawerProps) {\n  const titleId = useId();\n  const hintId = useId();\n\n  const drawer = useDrawer({\n    open,\n    onOpenChange,\n    side,\n    width,\n    modal: container === \"viewport\",\n  });\n\n  const edge =\n    side === \"right\"\n      ? \"right-0 rounded-l-[14px] border-l\"\n      : \"left-0 rounded-r-[14px] border-r\";\n\n  const [host, setHost] = useState<HTMLElement | null>(null);\n  useEffect(() => {\n    setHost(container === \"viewport\" ? document.body : null);\n  }, [container]);\n\n  const tree = (\n    <div\n      ref={drawer.rootRef}\n      className={`${container === \"viewport\" ? \"fixed\" : \"absolute\"} inset-0 z-50 overflow-hidden ${\n        open ? \"\" : \"pointer-events-none\"\n      }`}\n    >\n      <motion.div\n        aria-hidden\n        style={{ opacity: drawer.veil }}\n        onClick={dismissOnScrimClick ? drawer.close : undefined}\n        className=\"absolute inset-0 bg-stone-900/25 dark:bg-black/55\"\n      />\n      <motion.div\n        ref={drawer.panelRef}\n        aria-labelledby={titleId}\n        aria-describedby={hintId}\n        style={{ x: drawer.x, width, maxWidth: \"calc(100% - 40px)\", touchAction: \"pan-y\" }}\n        className={`absolute inset-y-0 flex flex-col border-stone-200 bg-white shadow-[0_28px_56px_-24px_rgba(24,22,20,0.45)] outline-none dark:border-white/[0.16] dark:bg-[#1D1D1A] ${edge} ${\n          drawer.dragging ? \"select-none\" : \"\"\n        } ${className}`}\n        {...drawer.panelProps}\n      >\n        <header\n          onPointerDown={drawer.gripProps.onPointerDown}\n          className={`flex select-none items-start gap-3 border-b border-stone-200 px-4 py-3 dark:border-white/[0.16] ${\n            drawer.dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n          }`}\n        >\n          <div className=\"min-w-0 flex-1\">\n            <h2\n              id={titleId}\n              className=\"truncate text-[13px] font-medium text-stone-700 dark:text-stone-200\"\n            >\n              {title}\n            </h2>\n            {description ? (\n              <p className=\"mt-0.5 truncate text-[12.5px] text-stone-500 dark:text-stone-400\">\n                {description}\n              </p>\n            ) : null}\n          </div>\n          <button\n            type=\"button\"\n            onPointerDown={(e) => e.stopPropagation()}\n            onClick={drawer.close}\n            aria-label={closeLabel}\n            className=\"-mr-1 grid size-7 shrink-0 place-items-center rounded-[7px] text-stone-400 outline-none transition-colors duration-150 hover:bg-stone-100 hover:text-stone-700 focus-visible:bg-[#4568FF]/[0.06] focus-visible:text-stone-700 focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:text-stone-500 dark:hover:bg-white/10 dark:hover:text-stone-100 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:text-stone-100 dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n          >\n            {CLOSE_ICON}\n          </button>\n        </header>\n        <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3\">\n          {children}\n        </div>\n\n        {footer ? (\n          <div className=\"border-t border-stone-200 px-4 py-3 dark:border-white/[0.16]\">\n            {footer}\n          </div>\n        ) : null}\n\n        <span id={hintId} className=\"sr-only\">\n          {hint}\n        </span>\n      </motion.div>\n    </div>\n  );\n\n  if (container !== \"viewport\") return tree;\n  return host ? createPortal(tree, host) : null;\n}\n"
    }
  ]
}
