{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mobile-menu",
  "type": "registry:ui",
  "title": "Mobile Menu",
  "description": "Phone menu whose burger turns into the close, with sections that drill in.",
  "categories": [
    "app-nav"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/mobile-menu.tsx",
      "content": "\"use client\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport { AnimatePresence, motion, useReducedMotion, type Transition } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type MobileMenuItem = {\n  label: string;\n  /** Where the row goes. Rows with `items` open a nested view instead. */\n  href?: string;\n  /** One line under the label in nested views. */\n  description?: string;\n  /** A 16px icon, shown in nested views. */\n  icon?: React.ReactNode;\n  /** Children open as a view that slides in from the right. Nest as deep as you need. */\n  items?: MobileMenuItem[];\n};\n\n/* -------------------------------------------------------------------------------------------------\n * The two-bar icon\n * -----------------------------------------------------------------------------------------------*/\n\n// Two bars that meet in the middle and then turn into a cross; closing turns them\n// back first and then parts them. The order is what makes it read as one object\n// changing shape rather than two icons swapping.\nfunction Bars({ open, reduce }: { open: boolean; reduce: boolean }) {\n  const openT: Transition = reduce ? { duration: 0 } : { y: { duration: 0.14, ease: ease.out }, rotate: { ...spring.snappy, delay: 0.08 } };\n  const closeT: Transition = reduce ? { duration: 0 } : { rotate: { duration: 0.14, ease: ease.out }, y: { duration: 0.16, ease: ease.out, delay: 0.1 } };\n  return (\n    <span aria-hidden className=\"relative block size-4\">\n      {[-1, 1].map((side) => (\n        <motion.span\n          key={side}\n          className=\"absolute inset-x-px top-1/2 -mt-[0.75px] h-[1.5px] rounded-full bg-current\"\n          initial={{ y: side * 3.5, rotate: 0 }}\n          animate={open ? { y: 0, rotate: side * 45 } : { y: side * 3.5, rotate: 0 }}\n          transition={open ? openT : closeT}\n        />\n      ))}\n    </span>\n  );\n}\n\nconst toggleClass = cn(\n  \"relative grid size-10 shrink-0 place-items-center rounded-lg text-fg outline-none\",\n  \"transition-[background-color,scale] duration-150 hover:bg-hover active:scale-[0.92] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"before:absolute before:-inset-0.5 before:content-['']\",\n);\n\n/* -------------------------------------------------------------------------------------------------\n * Root\n * -----------------------------------------------------------------------------------------------*/\n\ntype Geometry = { left: number; top: number; width: number; height: number; panelTop: number };\n\nexport type MobileMenuProps = {\n  items: MobileMenuItem[];\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** The href of the page being shown; that row is marked current. */\n  current?: string;\n  /** Called when a link row is pressed, before the menu closes. Call preventDefault to handle routing yourself. */\n  onNavigate?: (item: MobileMenuItem, event: React.MouseEvent<HTMLAnchorElement>) => void;\n  /** Render link rows with your router's link. Spread the props onto it. */\n  renderLink?: (item: MobileMenuItem, props: React.ComponentProps<\"a\">) => React.ReactElement;\n  /** Pinned to the bottom of the panel, in thumb reach: sign in, start trial. */\n  footer?: React.ReactNode;\n  /** Render inside this element instead of the viewport (a frame, a preview). It needs position: relative. */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Where the panel starts, in px from the top. Defaults to the bottom of the trigger's header. */\n  top?: number;\n  /** The dialog's accessible name. */\n  title?: string;\n  className?: string;\n};\n\nexport function MobileMenu({\n  items,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  current,\n  onNavigate,\n  renderLink,\n  footer,\n  container,\n  top,\n  title = \"Menu\",\n  className,\n}: MobileMenuProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const reduce = !!useReducedMotion();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const [geo, setGeo] = useState<Geometry | null>(null);\n  // The page's burger hides from the moment the menu opens until it has fully closed,\n  // so the close button can morph back into it without two icons overlapping.\n  const [settled, setSettled] = useState(!defaultOpen);\n  const [path, setPath] = useState<number[]>([]);\n  const [dir, setDir] = useState(1);\n  const [drilled, setDrilled] = useState(false);\n  const returnTo = useRef<string | null>(null);\n  const viewRef = useRef<HTMLDivElement>(null);\n\n  // Measure where the burger is, so the close button lands exactly on it and the\n  // panel starts right under the header it lives in.\n  const measure = () => {\n    const t = triggerRef.current;\n    if (!t) return;\n    const origin = container?.current?.getBoundingClientRect() ?? { left: 0, top: 0 };\n    const r = t.getBoundingClientRect();\n    const header = t.closest(\"header\")?.getBoundingClientRect();\n    setGeo({\n      left: r.left - origin.left,\n      top: r.top - origin.top,\n      width: r.width,\n      height: r.height,\n      panelTop: top ?? (header ? header.bottom : r.bottom + 8) - origin.top,\n    });\n  };\n\n  useEffect(() => {\n    if (!open) return;\n    const onResize = () => measure();\n    window.addEventListener(\"resize\", onResize);\n    return () => window.removeEventListener(\"resize\", onResize);\n  });\n\n  // Drilling in focuses the new view's back button; going back returns focus to the\n  // row that opened the view. Both happen after the view has mounted.\n  useEffect(() => {\n    const view = viewRef.current;\n    if (!view || !drilled) return;\n    const target = returnTo.current ? view.querySelector<HTMLElement>(`[data-row=\"${CSS.escape(returnTo.current)}\"]`) : view.querySelector<HTMLElement>(\"[data-view-focus]\");\n    target?.focus({ preventScroll: true });\n    returnTo.current = null;\n  }, [path, drilled]);\n\n  let list = items;\n  const trail: MobileMenuItem[] = [];\n  for (const i of path) {\n    const next = list[i];\n    if (!next?.items) break;\n    trail.push(next);\n    list = next.items;\n  }\n  const section = trail[trail.length - 1];\n\n  const push = (i: number) => {\n    setDir(1);\n    setDrilled(true);\n    returnTo.current = null;\n    setPath((p) => [...p, i]);\n  };\n  const pop = () => {\n    setDir(-1);\n    returnTo.current = section?.label ?? null;\n    setPath((p) => p.slice(0, -1));\n  };\n\n  const views = {\n    enter: (d: number) => (reduce ? { opacity: 0 } : { x: d > 0 ? \"100%\" : \"-24%\", opacity: d > 0 ? 1 : 0 }),\n    center: { x: 0, opacity: 1 },\n    exit: (d: number) => (reduce ? { opacity: 0 } : { x: d > 0 ? \"-24%\" : \"100%\", opacity: d > 0 ? 0 : 1 }),\n  };\n\n  return (\n    <Dialog.Root\n      open={open}\n      onOpenChange={(next, details) => {\n        // Escape inside a nested view steps back one level instead of closing everything.\n        if (!next && details.reason === \"escape-key\" && path.length) {\n          details.cancel();\n          pop();\n          return;\n        }\n        if (next) {\n          measure();\n          setSettled(false);\n          setPath([]);\n          setDrilled(false);\n        }\n        setOpen(next);\n      }}\n      onOpenChangeComplete={(next) => {\n        if (!next) setSettled(true);\n      }}\n    >\n      <Dialog.Trigger ref={triggerRef} aria-label=\"Open menu\" data-hidden={!settled || undefined} className={cn(toggleClass, \"data-hidden:opacity-0\", className)}>\n        <Bars open={false} reduce={reduce} />\n      </Dialog.Trigger>\n\n      <Dialog.Portal container={container}>\n        <Dialog.Popup\n          // The popup is a transparent sheet over everything; only the close button and\n          // the panel take pointer events. Its own near-invisible fade exists so Base UI\n          // waits for the panel's exit before unmounting.\n          className={cn(\n            \"group/popup inset-0 z-(--z-dialog) outline-none pointer-events-none\",\n            container ? \"absolute\" : \"fixed\",\n            \"transition-opacity duration-320 data-starting-style:opacity-[0.99] data-ending-style:opacity-[0.99] data-ending-style:duration-240\",\n          )}\n        >\n          <Dialog.Title className=\"sr-only\">{title}</Dialog.Title>\n          {geo && (\n            <Dialog.Close aria-label=\"Close menu\" className={cn(toggleClass, \"pointer-events-auto absolute\")} style={{ left: geo.left, top: geo.top, width: geo.width, height: geo.height }}>\n              <Bars open={open} reduce={reduce} />\n            </Dialog.Close>\n          )}\n\n          <div\n            className={cn(\n              \"pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col border-t border-line bg-frame\",\n              // The panel unrolls from the header down, and rolls back up faster.\n              \"[clip-path:inset(0_0_0_0)] transition-[clip-path] duration-320 ease-drawer\",\n              \"group-data-starting-style/popup:[clip-path:inset(0_0_100%_0)] group-data-ending-style/popup:[clip-path:inset(0_0_100%_0)] group-data-ending-style/popup:duration-240\",\n              \"motion-reduce:transition-opacity motion-reduce:group-data-starting-style/popup:opacity-0 motion-reduce:group-data-ending-style/popup:opacity-0\",\n            )}\n            style={{ top: geo?.panelTop ?? 56 }}\n          >\n            <div className=\"relative min-h-0 flex-1 overflow-hidden\">\n              <AnimatePresence initial={false} custom={dir} mode=\"popLayout\">\n                <motion.div\n                  key={path.join(\".\") || \"root\"}\n                  ref={viewRef}\n                  custom={dir}\n                  variants={views}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={reduce ? { duration: 0.15 } : { duration: 0.36, ease: ease.drawer }}\n                  className=\"absolute inset-0 overflow-y-auto overscroll-contain bg-frame px-4 pb-6 pt-2\"\n                >\n                  {section && (\n                    <div className=\"mb-2\">\n                      <button\n                        type=\"button\"\n                        data-view-focus=\"\"\n                        onClick={pop}\n                        className=\"-ml-2 flex h-11 items-center gap-1 rounded-lg pl-1 pr-3 text-[15px] text-fg-2 outline-none transition-[color,scale] duration-150 active:scale-[0.97] hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\"\n                      >\n                        <ChevronLeft size={18} />\n                        {trail[trail.length - 2]?.label ?? title}\n                      </button>\n                      <h2 className=\"px-0.5 pt-1 text-[22px] font-medium leading-tight tracking-[-0.02em] text-fg\">{section.label}</h2>\n                    </div>\n                  )}\n                  <ul className={cn(\"flex flex-col\", !section && \"divide-y divide-line\")}>\n                    {list.map((item, i) => (\n                      <Row\n                        key={item.label}\n                        item={item}\n                        index={i}\n                        nested={!!section}\n                        stagger={!drilled && !section}\n                        reduce={reduce}\n                        current={!!current && item.href === current}\n                        onOpen={() => push(i)}\n                        onNavigate={(e) => {\n                          onNavigate?.(item, e);\n                          setOpen(false);\n                        }}\n                        renderLink={renderLink}\n                      />\n                    ))}\n                  </ul>\n                </motion.div>\n              </AnimatePresence>\n            </div>\n            {footer && <div className=\"flex shrink-0 gap-2 border-t border-line p-4 pb-[max(16px,env(safe-area-inset-bottom))] *:flex-1\">{footer}</div>}\n          </div>\n        </Dialog.Popup>\n      </Dialog.Portal>\n    </Dialog.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Rows\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Row({\n  item,\n  index,\n  nested,\n  stagger,\n  reduce,\n  current,\n  onOpen,\n  onNavigate,\n  renderLink,\n}: {\n  item: MobileMenuItem;\n  index: number;\n  nested: boolean;\n  stagger: boolean;\n  reduce: boolean;\n  current: boolean;\n  onOpen: () => void;\n  onNavigate: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n  renderLink?: MobileMenuProps[\"renderLink\"];\n}) {\n  const rowClass = cn(\n    \"group/row flex w-full min-w-0 items-center gap-3 rounded-lg text-left text-fg no-underline outline-none\",\n    \"transition-[background-color,scale] duration-150 active:scale-[0.985] active:bg-hover active:duration-75\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n    nested ? \"min-h-14 px-2 py-2.5 -mx-2 w-[calc(100%+16px)]\" : \"h-14 text-[17px] font-medium tracking-[-0.01em]\",\n  );\n\n  const body = (\n    <>\n      {nested && item.icon && (\n        <span aria-hidden className=\"grid size-9 shrink-0 place-items-center rounded-lg border border-line-2 bg-raised text-fg-2 [&_svg]:size-4\">\n          {item.icon}\n        </span>\n      )}\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <span className={cn(\"truncate\", nested && \"text-[15px] font-medium tracking-[-0.01em]\")}>{item.label}</span>\n        {nested && item.description && <span className=\"line-clamp-2 text-[13px] leading-[18px] text-fg-3\">{item.description}</span>}\n      </span>\n      {current && <span aria-hidden className=\"size-1.5 shrink-0 rounded-full bg-fg\" />}\n      {item.items && <ChevronRight size={18} className=\"shrink-0 text-fg-3 transition-transform duration-150 group-active/row:translate-x-0.5\" />}\n    </>\n  );\n\n  let control: React.ReactNode;\n  if (item.items) {\n    control = (\n      <button type=\"button\" data-row={item.label} onClick={onOpen} className={rowClass}>\n        {body}\n      </button>\n    );\n  } else {\n    const props: React.ComponentProps<\"a\"> = {\n      href: item.href,\n      \"aria-current\": current ? \"page\" : undefined,\n      className: rowClass,\n      onClick: onNavigate,\n      children: body,\n    };\n    control = renderLink ? renderLink(item, props) : <a {...props} />;\n  }\n\n  // Rows arrive one after another only when the menu opens; views reached by drilling\n  // in slide as a whole, and nothing restaggers on the way back.\n  return (\n    <motion.li\n      initial={stagger ? (reduce ? { opacity: 0 } : { opacity: 0, y: 10 }) : false}\n      animate={{ opacity: 1, y: 0 }}\n      transition={reduce ? { duration: 0.15 } : { duration: 0.34, ease: ease.out, delay: 0.08 + Math.min(index, 7) * 0.03 }}\n    >\n      {control}\n    </motion.li>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Close\n * -----------------------------------------------------------------------------------------------*/\n\nexport type MobileMenuCloseProps = React.ComponentProps<typeof Dialog.Close>;\n\n/** Closes the menu. Use it for footer buttons: pass your button or link through `render`. */\nexport function MobileMenuClose(props: MobileMenuCloseProps) {\n  return <Dialog.Close {...props} />;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/mobile-menu"
}
