{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "mobile-drawer", "type": "registry:ui", "title": "Mobile Drawer", "description": "Slide-in navigation drawer with scrim, focus trap, and scroll lock. Base UI flavor.", "dependencies": [ "@base-ui/react", "framer-motion", "tw-animate-css" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "https://zeron-ui.vercel.app/r/utils.json", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/surface-context.json", "https://zeron-ui.vercel.app/r/surface-classes.json", "https://zeron-ui.vercel.app/r/portal-container-context.json" ], "files": [ { "path": "packages/ui/src/components/mobile-drawer.tsx", "content": "\"use client\";\nimport { useEffect, useRef, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { Dialog as DialogPrimitive } from \"@base-ui/react/dialog\";\nimport { useDirection } from \"@base-ui/react/direction-provider\";\nimport { motion } from \"framer-motion\";\nimport { cn } from \"@lib/utils\";\nimport { spring, exitFallbackMs } from \"@lib/springs\";\nimport { resolveSurface, useSurface, SurfaceProvider, } from \"@lib/surface-context\";\nimport { surfaceClasses } from \"@lib/surface-classes\";\nimport { usePortalContainer } from \"@lib/portal-container-context\";\n// Built on Base UI Dialog rather than Base UI Drawer: Drawer's\n// swipe-to-dismiss writes inline `transform` + `--drawer-swipe-movement-*`\n// CSS vars onto its Popup and expects CSS-transition choreography (plus a\n// mandatory ), which fights framer-motion's transform\n// management on the same element. Dialog provides everything we actually\n// need — scroll lock (scrollbar-gap safe, blocks iOS touch scrolling),\n// focus trap, focus restore timed after close, Esc + outside-click\n// dismissal — while leaving the slide animation to framer-motion.\nexport interface MobileDrawerProps {\n open: boolean;\n onClose: () => void;\n children: ReactNode;\n triggerRef?: RefObject;\n ariaLabel?: string;\n /** Explicit direction for portals rendered outside a locally directed subtree. */\n dir?: string;\n /** Logical edge from which the drawer enters. */\n side?: \"start\" | \"end\";\n panelClassName?: string;\n panelStyle?: CSSProperties;\n}\n// Props framer-motion redefines with incompatible signatures; they must not\n// be forwarded from Base UI's render-prop payload onto a motion.div.\ntype MotionSafeDivProps = Omit, \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\" | \"onAnimationIteration\">;\nexport function MobileDrawer({ open, onClose, children, triggerRef, ariaLabel = \"Navigation\", dir: dirProp, side = \"start\", panelClassName, panelStyle, }: MobileDrawerProps) {\n const contextDir = useDirection();\n const dir = dirProp ?? contextDir;\n const substrate = useSurface();\n const surface = resolveSurface(substrate, \"overlay\");\n const physicalLeft = (side === \"start\") === (dir !== \"rtl\");\n const hiddenX = physicalLeft ? \"-100%\" : \"100%\";\n const portalContainer = usePortalContainer();\n // With `actionsRef` set, Base UI defers unmounting the portal on close\n // until `actionsRef.current.unmount()` is called, letting the\n // framer-motion exit tween below play out first.\n const actionsRef = useRef(null);\n // Fallback release for the deferred unmount: onAnimationComplete on the\n // panel is the primary signal, but rAF-driven animation callbacks can\n // stall in throttled/background tabs. The longest exit tween is\n // spring.moderate.exit (backdrop), so the fallback tracks that tier's exit\n // duration plus a safety buffer.\n useEffect(() => {\n if (open)\n return;\n const id = setTimeout(() => actionsRef.current?.unmount(), exitFallbackMs(spring.moderate));\n return () => clearTimeout(id);\n }, [open]);\n return ( {\n if (!nextOpen)\n onClose();\n }} actionsRef={actionsRef}>\n \n {/* Overlay — same scrim as the library's dialogs: an always-on\n bg-scrim base that stays visible for system-dark users (the\n `dark:` variant only matches the explicit .dark class), boosted\n to /80 in explicit dark mode. */}\n {\n const { style: _style, ...rest } = backdropProps as React.HTMLAttributes;\n return ();\n }}/>\n\n {/* Panel */}\n {\n const { style: baseStyle, ...rest } = popupProps as React.HTMLAttributes;\n return ( {\n if (!open)\n actionsRef.current?.unmount();\n }}>\n {children}\n );\n }}/>\n \n );\n}\nexport default MobileDrawer;\n", "type": "registry:ui", "target": "components/ui/mobile-drawer.tsx" } ] }