{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "overlay-menu",
  "type": "registry:ui",
  "title": "Overlay Menu",
  "description": "A fullscreen navigation overlay that grows from its hamburger button, with large links rising through masks.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/overlay-menu.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionTemplate, useMotionValue } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface OverlayMenuItem {\n  label: string;\n  href: string;\n  /** Small annotation beside the link, e.g. a count or \"New\". */\n  meta?: string;\n}\n\nexport interface OverlayMenuProps {\n  items: OverlayMenuItem[];\n  /** href of the current page; that link gets aria-current=\"page\". */\n  currentHref?: string;\n  /** Open state (controlled). */\n  open?: boolean;\n  /** Initial open state (uncontrolled). */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Called when a link is activated. Call event.preventDefault() for client-side routing. */\n  onNavigate?: (item: OverlayMenuItem, event: React.MouseEvent<HTMLAnchorElement>) => void;\n  /** Close the overlay after a link is activated. */\n  closeOnNavigate?: boolean;\n  /** Secondary column (contact, socials, hours…). Hidden below the md breakpoint. */\n  aside?: React.ReactNode;\n  /** How the background appears: a circle growing from the button, or a fade. */\n  reveal?: \"circle\" | \"fade\";\n  /** Overlay colours: inverse of the page theme, the accent, or the page surface. */\n  tone?: \"inverse\" | \"accent\" | \"surface\";\n  /** Show 01, 02… before each link. */\n  showIndex?: boolean;\n  /** Length of the background reveal, in ms. */\n  duration?: number;\n  /** Delay between links as they rise into view, in ms. */\n  stagger?: number;\n  /** Accent colour (accent tone background, markers, focus rings). */\n  accent?: string;\n  /** Accessible name of the dialog and its nav. */\n  title?: string;\n  /** Accessible name of the trigger when closed. */\n  openLabel?: string;\n  /** Accessible name of the close button. */\n  closeLabel?: string;\n  /** Classes added to the trigger button. */\n  className?: string;\n}\n\nconst TONES: Record<NonNullable<OverlayMenuProps[\"tone\"]>, string> = {\n  inverse: \"bg-zinc-950 text-zinc-50 dark:bg-zinc-50 dark:text-zinc-950\",\n  accent: \"bg-[var(--om-accent)] text-zinc-950\",\n  surface: \"bg-white text-zinc-950 dark:bg-zinc-900 dark:text-zinc-50\",\n};\n\nconst EASE = [0.76, 0, 0.24, 1] as const;\nconst EASE_OUT = [0.16, 1, 0.3, 1] as const;\n\n/** Two bars that morph between a hamburger and a cross. */\nfunction MorphIcon({ open, reduceMotion }: { open: boolean; reduceMotion: boolean }) {\n  const transition = reduceMotion ? { duration: 0 } : { duration: 0.35, ease: EASE };\n  return (\n    <svg viewBox=\"0 0 24 24\" className=\"h-5 w-5\" aria-hidden=\"true\" fill=\"none\">\n      <motion.line\n        x1=\"4\" x2=\"20\" y1=\"9\" y2=\"9\"\n        stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\"\n        style={{ originX: \"50%\", originY: \"50%\" }}\n        initial={false}\n        animate={open ? { rotate: 45, y: 3 } : { rotate: 0, y: 0 }}\n        transition={transition}\n      />\n      <motion.line\n        x1=\"4\" x2=\"20\" y1=\"15\" y2=\"15\"\n        stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\"\n        style={{ originX: \"50%\", originY: \"50%\" }}\n        initial={false}\n        animate={open ? { rotate: -45, y: -3 } : { rotate: 0, y: 0 }}\n        transition={transition}\n      />\n    </svg>\n  );\n}\n\n/**\n * A fullscreen navigation overlay on a native modal <dialog>. The background grows as a circle\n * from the trigger, large links rise through masks with a stagger, and a secondary column fades in.\n * showModal() provides the focus trap, Escape and inert page; focus returns to the trigger on close.\n */\nexport function OverlayMenu({\n  items,\n  currentHref,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  onNavigate,\n  closeOnNavigate = true,\n  aside,\n  reveal = \"circle\",\n  tone = \"inverse\",\n  showIndex = true,\n  duration = 700,\n  stagger = 60,\n  accent = \"#FF7448\",\n  title = \"Menu\",\n  openLabel = \"Open menu\",\n  closeLabel = \"Close menu\",\n  className = \"\",\n}: OverlayMenuProps) {\n  const uid = React.useId();\n  const dialogId = `${uid}-dialog`;\n  const reduceMotion = usePrefersReducedMotion();\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n  const isOpen = open ?? internalOpen;\n\n  const setOpen = (next: boolean) => {\n    if (open === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n\n  // Circle reveal geometry, written in effects (never during render).\n  const originX = useMotionValue(0);\n  const originY = useMotionValue(0);\n  const radius = useMotionValue(0);\n  const clipPath = useMotionTemplate`circle(${radius}px at ${originX}px ${originY}px)`;\n  const useCircle = reveal === \"circle\" && !reduceMotion;\n\n  const seconds = duration / 1000;\n\n  const runOpen = React.useEffectEvent(() => {\n    const trigger = triggerRef.current?.getBoundingClientRect();\n    const w = window.innerWidth;\n    const h = window.innerHeight;\n    const x = trigger ? trigger.left + trigger.width / 2 : w - 40;\n    const y = trigger ? trigger.top + trigger.height / 2 : 40;\n    originX.set(x);\n    originY.set(y);\n    // Radius that reaches the farthest viewport corner.\n    const r = Math.hypot(Math.max(x, w - x), Math.max(y, h - y)) + 8;\n    radius.set(0);\n    return animate(radius, r, useCircle ? { duration: seconds, ease: EASE } : { duration: 0 });\n  });\n\n  const runClose = React.useEffectEvent(() =>\n    animate(radius, 0, useCircle ? { duration: seconds * 0.75, ease: EASE, delay: 0.12 } : { duration: reduceMotion ? 0 : 0.3 }),\n  );\n\n  // Drive the native dialog from state: showModal → reveal; close → retract, then close().\n  React.useEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n    if (isOpen) {\n      if (!dialog.open) dialog.showModal();\n      const controls = runOpen();\n      return () => controls.stop();\n    }\n    if (dialog.open) {\n      let cancelled = false;\n      const controls = runClose();\n      controls.then(() => {\n        if (cancelled) return;\n        dialog.close();\n        triggerRef.current?.focus({ preventScroll: true });\n      });\n      return () => {\n        cancelled = true;\n        controls.stop();\n      };\n    }\n  }, [isOpen]);\n\n  // Body scroll lock while open, compensating for the scrollbar so the page does not shift.\n  React.useEffect(() => {\n    if (!isOpen) return;\n    const html = document.documentElement;\n    const body = document.body;\n    const scrollbar = window.innerWidth - html.clientWidth;\n    const prev = { overflow: html.style.overflow, paddingRight: body.style.paddingRight };\n    html.style.overflow = \"hidden\";\n    if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`;\n    return () => {\n      html.style.overflow = prev.overflow;\n      body.style.paddingRight = prev.paddingRight;\n    };\n  }, [isOpen]);\n\n  const state = isOpen ? \"open\" : \"closed\";\n  const step = stagger / 1000;\n  const textDelay = useCircle ? seconds * 0.35 : 0.05;\n\n  const listVariants = {\n    open: { transition: { staggerChildren: reduceMotion ? 0 : step, delayChildren: reduceMotion ? 0 : textDelay } },\n    closed: { transition: { staggerChildren: reduceMotion ? 0 : step * 0.5, staggerDirection: -1 } },\n  };\n  const linkVariants = reduceMotion\n    ? { open: { y: \"0%\", opacity: 1, transition: { duration: 0.2 } }, closed: { y: \"0%\", opacity: 0, transition: { duration: 0.15 } } }\n    : {\n        open: { y: \"0%\", opacity: 1, transition: { duration: 0.75, ease: EASE_OUT } },\n        closed: { y: \"110%\", opacity: 1, transition: { duration: 0.35, ease: EASE } },\n      };\n  const fadeVariants = {\n    open: { opacity: 1, y: 0, transition: { duration: reduceMotion ? 0.2 : 0.6, delay: reduceMotion ? 0 : textDelay + 0.15, ease: EASE_OUT } },\n    closed: { opacity: 0, y: reduceMotion ? 0 : 8, transition: { duration: 0.2 } },\n  };\n\n  const focusRing = \"focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--om-accent)]\";\n  const ringOnAccent = tone === \"accent\" ? \"focus-visible:outline-current\" : \"\";\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={isOpen}\n        aria-controls={dialogId}\n        aria-label={openLabel}\n        onClick={() => setOpen(!isOpen)}\n        className={`inline-flex h-11 w-11 items-center justify-center rounded-full border border-zinc-900/10 bg-white text-zinc-900 transition-colors hover:border-zinc-900/25 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:border-white/25 ${focusRing} focus-visible:outline-offset-2 ${className}`}\n        style={{ [\"--om-accent\" as string]: accent }}\n      >\n        <MorphIcon open={isOpen} reduceMotion={reduceMotion} />\n      </button>\n\n      <dialog\n        ref={dialogRef}\n        id={dialogId}\n        aria-label={title}\n        onCancel={(event) => {\n          // Escape: keep React state the source of truth so the exit animation can run.\n          event.preventDefault();\n          setOpen(false);\n        }}\n        onClose={() => {\n          if (isOpen) setOpen(false);\n        }}\n        className=\"m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 backdrop:bg-transparent\"\n        style={{ [\"--om-accent\" as string]: accent }}\n      >\n        <motion.div\n          className={`absolute inset-0 flex flex-col overflow-y-auto overscroll-contain ${TONES[tone]}`}\n          style={useCircle ? { clipPath } : undefined}\n          initial={false}\n          animate={useCircle ? { opacity: 1 } : { opacity: isOpen ? 1 : 0 }}\n          transition={{ duration: reduceMotion ? 0.15 : seconds * 0.5 }}\n        >\n          <div className=\"flex shrink-0 items-center justify-between px-5 py-4 sm:px-8 sm:py-6\">\n            <motion.p\n              className=\"font-mono text-[10px] uppercase tracking-[0.14em] opacity-60\"\n              variants={fadeVariants}\n              initial=\"closed\"\n              animate={state}\n            >\n              {title}\n            </motion.p>\n            <button\n              type=\"button\"\n              aria-label={closeLabel}\n              onClick={() => setOpen(false)}\n              className={`inline-flex h-11 w-11 items-center justify-center rounded-full border border-current/20 transition-colors hover:bg-current/10 ${focusRing} focus-visible:outline-offset-2 ${ringOnAccent}`}\n            >\n              <MorphIcon open={isOpen} reduceMotion={reduceMotion} />\n            </button>\n          </div>\n\n          <div className=\"grid flex-1 grid-cols-1 gap-10 px-5 pb-10 sm:px-8 md:grid-cols-[minmax(0,1fr)_minmax(0,18rem)] md:items-end md:gap-16 md:pb-14\">\n            <nav aria-label={title} className=\"min-w-0 self-center\">\n              <motion.ul className=\"group/nav\" variants={listVariants} initial=\"closed\" animate={state}>\n                {items.map((item, index) => {\n                  const current = item.href === currentHref;\n                  return (\n                    <li key={item.href}>\n                      <a\n                        href={item.href}\n                        aria-current={current ? \"page\" : undefined}\n                        onClick={(event) => {\n                          onNavigate?.(item, event);\n                          if (closeOnNavigate) setOpen(false);\n                        }}\n                        className={`block rounded-sm transition-opacity duration-300 group-hover/nav:opacity-40 hover:opacity-100! ${focusRing} ${ringOnAccent}`}\n                      >\n                        {/* The mask: the row rises from below this clipping box. */}\n                        <span className=\"block overflow-hidden pb-[0.06em]\">\n                          <motion.span variants={linkVariants} className=\"flex items-baseline gap-3 sm:gap-5\">\n                            {showIndex && (\n                              <span aria-hidden=\"true\" className=\"w-6 shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums opacity-50 sm:w-8\">\n                                {String(index + 1).padStart(2, \"0\")}\n                              </span>\n                            )}\n                            <span className=\"text-[clamp(2.4rem,7.5vw,5.75rem)] font-semibold leading-[1.02] tracking-[-0.035em]\">\n                              {item.label}\n                            </span>\n                            {current && (\n                              <span\n                                aria-hidden=\"true\"\n                                className={`h-2 w-2 shrink-0 self-center rounded-full ${tone === \"accent\" ? \"bg-current\" : \"bg-[var(--om-accent)]\"}`}\n                              />\n                            )}\n                            {item.meta && (\n                              <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] opacity-60\">{item.meta}</span>\n                            )}\n                          </motion.span>\n                        </span>\n                      </a>\n                    </li>\n                  );\n                })}\n              </motion.ul>\n            </nav>\n\n            {aside && (\n              <motion.div className=\"hidden min-w-0 text-sm md:block\" variants={fadeVariants} initial=\"closed\" animate={state}>\n                <div className=\"border-t border-current/15 pt-5\">{aside}</div>\n              </motion.div>\n            )}\n          </div>\n        </motion.div>\n      </dialog>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/overlay-menu"
}
