{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"globe-menu","type":"registry:ui","title":"Globe Menu","description":"Menu items wrapped around a sphere you drag to spin; it settles on the disc facing you and reveals its title.","dependencies":["motion","lucide-react"],"registryDependencies":["utils"],"files":[{"path":"registry/ui/globe-menu.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ArrowUpRight } from \"lucide-react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface GlobeMenuItem {\n  image: string;\n  title: string;\n  description?: string;\n  href?: string;\n}\n\nexport interface GlobeMenuProps {\n  items: GlobeMenuItem[];\n  /** Number of discs on the sphere; items repeat to fill it. */\n  count?: number;\n  /** Disc diameter in px. */\n  itemSize?: number;\n  /** Slowly spin while idle instead of settling. */\n  autoRotate?: boolean;\n  className?: string;\n}\n\ntype V3 = [number, number, number];\ntype M3 = number[]; // row-major 3x3\n\nconst mul = (a: M3, b: M3): M3 => {\n  const o = new Array(9);\n  for (let r = 0; r < 3; r++)\n    for (let c = 0; c < 3; c++) o[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];\n  return o;\n};\nconst apply = (m: M3, v: V3): V3 => [\n  m[0] * v[0] + m[1] * v[1] + m[2] * v[2],\n  m[3] * v[0] + m[4] * v[1] + m[5] * v[2],\n  m[6] * v[0] + m[7] * v[1] + m[8] * v[2],\n];\n/** Rotation matrix around a unit axis (Rodrigues). */\nconst axisAngle = ([x, y, z]: V3, a: number): M3 => {\n  const c = Math.cos(a);\n  const s = Math.sin(a);\n  const t = 1 - c;\n  return [t * x * x + c, t * x * y - s * z, t * x * z + s * y, t * x * y + s * z, t * y * y + c, t * y * z - s * x, t * x * z - s * y, t * y * z + s * x, t * z * z + c];\n};\n\n/** Evenly spread points on a unit sphere (golden spiral). */\nconst fibonacci = (n: number): V3[] =>\n  Array.from({ length: n }, (_, i) => {\n    const y = 1 - ((i + 0.5) / n) * 2;\n    const r = Math.sqrt(1 - y * y);\n    const t = i * Math.PI * (3 - Math.sqrt(5));\n    return [Math.cos(t) * r, y, Math.sin(t) * r];\n  });\n\nexport function GlobeMenu({ items, count = 42, itemSize = 84, autoRotate = false, className }: GlobeMenuProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const discRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const [active, setActive] = useState(0);\n  const [moving, setMoving] = useState(false);\n  const n = Math.max(items.length, Math.round(count));\n  const cfg = useRef({ autoRotate });\n  useEffect(() => {\n    cfg.current = { autoRotate };\n  }, [autoRotate]);\n\n  useEffect(() => {\n    const root = rootRef.current!;\n    const pts = fibonacci(n);\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let m: M3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];\n    let omega: V3 = [0, 0, 0]; // angular velocity, axis * rad/s\n    let target = -1; // disc index to bring to front (keyboard)\n    let drag: { x: number; y: number; t: number } | null = null;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let front = -1;\n    let isMoving = false;\n\n    const radius = () => Math.min(root.clientWidth, root.clientHeight) * 0.36;\n    const setMovingOnce = (v: boolean) => {\n      if (v === isMoving) return;\n      isMoving = v;\n      setMoving(v);\n    };\n\n    const rotate = (axis: V3, angle: number) => {\n      const len = Math.hypot(...axis);\n      if (len < 1e-9 || !angle) return;\n      m = mul(axisAngle([axis[0] / len, axis[1] / len, axis[2] / len], angle), m);\n    };\n\n    const render = () => {\n      const R = radius();\n      let best = 0;\n      let bestZ = -2;\n      for (let i = 0; i < n; i++) {\n        const [x, y, z] = apply(m, pts[i]);\n        if (z > bestZ) {\n          bestZ = z;\n          best = i;\n        }\n        const el = discRefs.current[i];\n        if (!el) continue;\n        // Billboard squashed along the radial direction: reads as a disc on a sphere.\n        const theta = Math.atan2(y, x);\n        const squash = Math.max(0.25, Math.abs(z));\n        const depth = (z + 1) / 2;\n        el.style.transform = `translate3d(${x * R}px,${y * R}px,${z * R}px) rotate(${theta}rad) scaleX(${squash}) rotate(${-theta}rad)`;\n        el.style.opacity = String(0.12 + depth * 0.88);\n        el.style.zIndex = String(Math.round(z * 1000) + 1000);\n        el.style.filter = z < 0 ? `blur(${(-z * 3).toFixed(1)}px) grayscale(.6)` : \"none\";\n      }\n      if (best !== front) {\n        front = best;\n        setActive(best % items.length);\n        discRefs.current.forEach((el, i) => el?.toggleAttribute(\"data-front\", i === best));\n      }\n      return best;\n    };\n\n    const tick = (now: number) => {\n      const dt = Math.min(0.05, (now - last) / 1000 || 0.016);\n      last = now;\n      let settled = false;\n      if (!drag) {\n        const speed = Math.hypot(...omega);\n        if (speed > 0.05) {\n          rotate(omega, speed * dt);\n          const k = Math.exp(-dt * 2.5);\n          omega = [omega[0] * k, omega[1] * k, omega[2] * k];\n        } else if (cfg.current.autoRotate && !reduced && target < 0) {\n          rotate([0, 1, 0], 0.18 * dt);\n        } else {\n          // Settle: swing the chosen disc to dead center.\n          omega = [0, 0, 0];\n          const i = target >= 0 ? target : front;\n          const q = apply(m, pts[Math.max(0, i)]);\n          const angle = Math.acos(Math.max(-1, Math.min(1, q[2])));\n          if (angle < 0.002) settled = true;\n          else rotate([q[1], -q[0], 0], reduced ? angle : angle * (1 - Math.exp(-dt * 9)));\n          if (settled) target = -1;\n        }\n      }\n      render();\n      setMovingOnce(!!drag || Math.hypot(...omega) > 0.4);\n      raf = visible && !settled ? requestAnimationFrame(tick) : 0;\n    };\n    const wake = () => {\n      if (raf || !visible) return;\n      last = performance.now();\n      raf = requestAnimationFrame(tick);\n    };\n\n    const onDown = (e: PointerEvent) => {\n      root.setPointerCapture(e.pointerId);\n      drag = { x: e.clientX, y: e.clientY, t: performance.now() };\n      omega = [0, 0, 0];\n      target = -1;\n      wake();\n    };\n    const onMove = (e: PointerEvent) => {\n      if (!drag) return;\n      const now = performance.now();\n      const dx = e.clientX - drag.x;\n      const dy = e.clientY - drag.y;\n      const angle = Math.hypot(dx, dy) / radius();\n      rotate([-dy, dx, 0], angle);\n      const dts = Math.max(0.008, (now - drag.t) / 1000);\n      const blend = 0.6;\n      omega = [\n        omega[0] * (1 - blend) + ((-dy / radius()) / dts) * blend,\n        omega[1] * (1 - blend) + ((dx / radius()) / dts) * blend,\n        0,\n      ];\n      drag = { x: e.clientX, y: e.clientY, t: now };\n    };\n    const onUp = () => {\n      if (drag && performance.now() - drag.t > 80) omega = [0, 0, 0]; // held still before letting go\n      drag = null;\n      wake();\n    };\n    const onKey = (e: KeyboardEvent) => {\n      const dir: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };\n      const d = dir[e.key];\n      if (!d) return;\n      e.preventDefault();\n      // Nearest front-facing disc in the pressed direction.\n      let best = -1;\n      let bestScore = Infinity;\n      for (let i = 0; i < n; i++) {\n        if (i === front) continue;\n        const [x, y, z] = apply(m, pts[i]);\n        const along = x * d[0] + y * d[1];\n        if (z < 0 || along < 0.12) continue;\n        const score = Math.hypot(x, y) + Math.abs(x * d[1] - y * d[0]) * 1.5;\n        if (score < bestScore) {\n          bestScore = score;\n          best = i;\n        }\n      }\n      if (best >= 0) {\n        target = best;\n        omega = [0, 0, 0];\n        wake();\n      }\n    };\n\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) wake();\n    });\n    const ro = new ResizeObserver(() => render());\n    io.observe(root);\n    ro.observe(root);\n    root.addEventListener(\"pointerdown\", onDown);\n    root.addEventListener(\"pointermove\", onMove);\n    root.addEventListener(\"pointerup\", onUp);\n    root.addEventListener(\"pointercancel\", onUp);\n    root.addEventListener(\"keydown\", onKey);\n    // Start with a little spin so the sphere reads as 3D right away.\n    omega = reduced ? [0, 0, 0] : [0.3, 2.4, 0];\n    wake();\n    return () => {\n      cancelAnimationFrame(raf);\n      io.disconnect();\n      ro.disconnect();\n      root.removeEventListener(\"pointerdown\", onDown);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerup\", onUp);\n      root.removeEventListener(\"pointercancel\", onUp);\n      root.removeEventListener(\"keydown\", onKey);\n    };\n  }, [n, items.length]);\n\n  const item = items[active];\n\n  return (\n    <div className={cn(\"relative h-full w-full overflow-hidden\", className)}>\n      <div\n        ref={rootRef}\n        tabIndex={0}\n        role=\"group\"\n        aria-roledescription=\"sphere menu\"\n        aria-label=\"Drag or use the arrow keys to spin.\"\n        className=\"absolute inset-0 cursor-grab touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:cursor-grabbing\"\n        style={{ perspective: 1100 }}\n      >\n        <div className=\"absolute left-1/2 top-1/2\" style={{ transformStyle: \"preserve-3d\" }}>\n          {Array.from({ length: n }, (_, i) => {\n            const it = items[i % items.length];\n            return (\n              <div\n                key={i}\n                ref={(el) => {\n                  discRefs.current[i] = el;\n                }}\n                aria-hidden\n                className=\"absolute overflow-hidden rounded-full bg-muted ring-1 ring-white/10 transition-[box-shadow] duration-300 will-change-transform data-[front]:shadow-[0_0_0_3px_var(--primary),0_0_40px_-4px_var(--primary)]\"\n                style={{ width: itemSize, height: itemSize, left: -itemSize / 2, top: -itemSize / 2 }}\n              >\n                {/* eslint-disable-next-line @next/next/no-img-element */}\n                <img src={it.image} alt=\"\" draggable={false} className=\"size-full object-cover\" />\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      <div className=\"pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-6 sm:p-8\">\n        <AnimatePresence mode=\"wait\">\n          {!moving && item && (\n            <motion.div\n              key={active}\n              role=\"status\"\n              initial={{ opacity: 0, y: 12, filter: \"blur(6px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, y: -8, filter: \"blur(6px)\" }}\n              transition={{ duration: 0.35, ease: [0.2, 0.7, 0.2, 1] }}\n              className=\"max-w-sm\"\n            >\n              <p className=\"text-3xl font-semibold tracking-tight text-foreground sm:text-5xl\">{item.title}</p>\n              {item.description && <p className=\"mt-2 text-sm text-muted-foreground\">{item.description}</p>}\n            </motion.div>\n          )}\n        </AnimatePresence>\n        {item?.href && (\n          <motion.a\n            href={item.href}\n            aria-label={`Open ${item.title}`}\n            animate={{ scale: moving ? 0 : 1, opacity: moving ? 0 : 1 }}\n            transition={{ type: \"spring\", stiffness: 400, damping: 22 }}\n            className=\"pointer-events-auto grid size-14 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground outline-none ring-ring ring-offset-2 ring-offset-background focus-visible:ring-2\"\n          >\n            <ArrowUpRight className=\"size-6\" />\n          </motion.a>\n        )}\n      </div>\n    </div>\n  );\n}\n"}]}