{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"drawer","type":"registry:ui","title":"Drawer","description":"A side panel that springs in from the left or right over a blurred scrim; drag it toward its edge, press Escape or click outside to dismiss.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/drawer.tsx","type":"registry:ui","target":"components/ui/drawer.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform, type PanInfo } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DrawerProps {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Element that opens the drawer; receives onClick and aria-expanded. */\n  trigger?: React.ReactElement<React.HTMLAttributes<HTMLElement>>;\n  /** Edge the panel slides from. */\n  side?: \"left\" | \"right\";\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  children?: React.ReactNode;\n  /** Sticky footer, e.g. actions. */\n  footer?: React.ReactNode;\n  /** Panel width in px (clamped to the viewport). */\n  width?: number;\n  /** Allow dragging the panel toward its edge to dismiss. */\n  dragToDismiss?: boolean;\n  className?: string;\n}\n\nconst FOCUSABLE =\n  'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nconst subscribe = () => () => {};\n\n/** Side panel that springs in from the left or right over a blurred scrim. Drag it toward its edge, press Escape or click outside to close. */\nexport function Drawer({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  trigger,\n  side = \"right\",\n  title,\n  description,\n  children,\n  footer,\n  width = 400,\n  dragToDismiss = true,\n  className,\n}: DrawerProps) {\n  const [inner, setInner] = React.useState(defaultOpen);\n  const open = openProp ?? inner;\n  const mounted = React.useSyncExternalStore(subscribe, () => true, () => false);\n\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInner(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  return (\n    <>\n      {trigger &&\n        React.cloneElement(trigger, {\n          \"aria-expanded\": open,\n          \"aria-haspopup\": \"dialog\",\n          onClick: (e: React.MouseEvent<HTMLElement>) => {\n            trigger.props.onClick?.(e);\n            if (!e.defaultPrevented) setOpen(true);\n          },\n        })}\n      {mounted &&\n        createPortal(\n          <AnimatePresence>\n            {open && (\n              <DrawerPanel\n                key=\"drawer\"\n                side={side}\n                title={title}\n                description={description}\n                footer={footer}\n                width={width}\n                dragToDismiss={dragToDismiss}\n                className={className}\n                onClose={() => setOpen(false)}\n              >\n                {children}\n              </DrawerPanel>\n            )}\n          </AnimatePresence>,\n          document.body,\n        )}\n    </>\n  );\n}\n\ninterface DrawerPanelProps extends Omit<DrawerProps, \"open\" | \"defaultOpen\" | \"onOpenChange\" | \"trigger\"> {\n  side: \"left\" | \"right\";\n  width: number;\n  dragToDismiss: boolean;\n  onClose: () => void;\n}\n\nfunction DrawerPanel({ side, title, description, children, footer, width, dragToDismiss, className, onClose }: DrawerPanelProps) {\n  const titleId = React.useId();\n  const descId = React.useId();\n  const reduce = useReducedMotion();\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const closeRef = React.useRef(onClose);\n  React.useEffect(() => {\n    closeRef.current = onClose;\n  }, [onClose]);\n\n  const sign = side === \"right\" ? 1 : -1;\n  const offscreen = sign * (width + 48);\n  const x = useMotionValue(offscreen);\n  // Scrim fades as the panel is dragged out.\n  const scrim = useTransform(x, [0, offscreen], [1, 0]);\n\n  React.useEffect(() => {\n    const prev = document.activeElement as HTMLElement | null;\n    const prevOverflow = document.body.style.overflow;\n    const prevPadding = document.body.style.paddingRight;\n    const gap = window.innerWidth - document.documentElement.clientWidth;\n    document.body.style.overflow = \"hidden\";\n    if (gap > 0) document.body.style.paddingRight = `${gap}px`;\n\n    const raf = requestAnimationFrame(() => {\n      const first = panelRef.current?.querySelector<HTMLElement>(\"[data-autofocus]\") ?? panelRef.current;\n      first?.focus({ preventScroll: true });\n    });\n    function onKey(e: KeyboardEvent) {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        closeRef.current();\n      }\n    }\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      cancelAnimationFrame(raf);\n      document.removeEventListener(\"keydown\", onKey);\n      document.body.style.overflow = prevOverflow;\n      document.body.style.paddingRight = prevPadding;\n      prev?.focus?.({ preventScroll: true });\n    };\n  }, []);\n\n  function trapTab(e: React.KeyboardEvent) {\n    if (e.key !== \"Tab\" || !panelRef.current) return;\n    const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));\n    if (!nodes.length) return;\n    const first = nodes[0];\n    const last = nodes[nodes.length - 1];\n    if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {\n      e.preventDefault();\n      last.focus();\n    } else if (!e.shiftKey && document.activeElement === last) {\n      e.preventDefault();\n      first.focus();\n    }\n  }\n\n  function onDragEnd(_: unknown, info: PanInfo) {\n    const travelled = info.offset.x * sign;\n    const flung = info.velocity.x * sign;\n    if (travelled > width * 0.35 || flung > 500) closeRef.current();\n  }\n\n  const spring = reduce ? { duration: 0.18 } : { type: \"spring\" as const, stiffness: 340, damping: 34 };\n\n  return (\n    <div className=\"fixed inset-0 z-[90]\">\n      <motion.div\n        aria-hidden\n        className=\"absolute inset-0 bg-scrim backdrop-blur-[6px]\"\n        style={{ opacity: scrim }}\n        onClick={() => closeRef.current()}\n      />\n      <motion.div\n        ref={panelRef}\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby={titleId}\n        aria-describedby={description ? descId : undefined}\n        tabIndex={-1}\n        onKeyDown={trapTab}\n        style={{ x, width: `min(${width}px, calc(100vw - 24px))` }}\n        initial={{ x: offscreen }}\n        animate={{ x: 0 }}\n        exit={{ x: offscreen }}\n        transition={spring}\n        drag={dragToDismiss && !reduce ? \"x\" : false}\n        dragDirectionLock\n        dragConstraints={{ left: 0, right: 0 }}\n        dragElastic={side === \"right\" ? { left: 0.04, right: 0.9 } : { left: 0.9, right: 0.04 }}\n        dragMomentum={false}\n        onDragEnd={onDragEnd}\n        className={cn(\n          \"absolute inset-y-2 flex flex-col overflow-hidden rounded-[20px] border border-border bg-surface-raised text-ink shadow-lg outline-none\",\n          side === \"right\" ? \"right-2\" : \"left-2\",\n          className,\n        )}\n      >\n        {dragToDismiss && (\n          <span\n            aria-hidden\n            className={cn(\n              \"absolute top-1/2 h-10 w-1 -translate-y-1/2 rounded-full bg-border-strong/50\",\n              side === \"right\" ? \"left-1.5\" : \"right-1.5\",\n            )}\n          />\n        )}\n        <header className=\"flex items-start gap-3 border-b border-border px-6 pb-4 pt-5\">\n          <div className=\"min-w-0 flex-1\">\n            <h2 id={titleId} className=\"font-display text-lg font-semibold leading-7 tracking-[-0.2px]\">\n              {title}\n            </h2>\n            {description && (\n              <p id={descId} className=\"mt-0.5 text-[13.5px] leading-5 text-ink-muted\">\n                {description}\n              </p>\n            )}\n          </div>\n          <button\n            type=\"button\"\n            aria-label=\"Close\"\n            onClick={() => closeRef.current()}\n            className=\"-mr-2 grid size-8 shrink-0 place-items-center rounded-[10px] text-ink-subtle outline-none hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]\"\n          >\n            <X className=\"size-4\" aria-hidden />\n          </button>\n        </header>\n        <motion.div\n          className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-5\"\n          initial={reduce ? false : { opacity: 0, x: sign * 16, filter: \"blur(6px)\" }}\n          animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n          transition={{ type: \"spring\", stiffness: 300, damping: 30, delay: 0.06 }}\n        >\n          {children}\n        </motion.div>\n        {footer && <footer className=\"border-t border-border bg-surface px-6 py-4\">{footer}</footer>}\n      </motion.div>\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","drawer","sheet","side panel","overlay","drag","gesture","dialog","cart"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/drawer"}}