{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gooey-menu","type":"registry:component","title":"Gooey menu","description":"One small button that opens three ways, all made of the same liquid: a menu that pulls free, a button that swells into one, and a dial that bubbles out.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/gooey-menu.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  animate,\n  motion,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// After Arek's GSAP playground (https://x.com/arknow91): three ways for one\n// small button to open, all made of the same liquid. Every shape is drawn\n// on a layer that is blurred and then sharpened back, so shapes that come\n// near each other melt together and stretch apart on necks. The button\n// also leans like a drop towards your pointer.\n//\n// Anchored: the menu pulls out of the button and snaps loose above it.\n// Morphing: the button swells into the menu.\n// Speed dial: three options bubble out around it.\n\ntype Mode = \"anchored\" | \"morphing\" | \"dial\";\nconst MODES: { id: Mode; label: string }[] = [\n  { id: \"anchored\", label: \"Anchored\" },\n  { id: \"morphing\", label: \"Morphing\" },\n  { id: \"dial\", label: \"Speed dial\" },\n];\n\n// Stage and parts, in px. The button sits low so menus have room above.\nconst W = 360;\nconst H = 340;\nconst CX = W / 2;\nconst CY = 236;\nconst BTN = 42;\nconst PANEL_W = 156;\nconst PANEL_H = 122;\nconst GAP = 14;\nconst DIAL = 40;\nconst DIAL_AT = [\n  { x: -60, y: -30 },\n  { x: 0, y: -70 },\n  { x: 60, y: -30 },\n];\n// How far the drop can lean out of the button towards the pointer, and\n// from how far away the pointer pulls it.\nconst LEAN = 26;\nconst PULL = 150;\n\n// Liquid moves on springs that overshoot a little: the stretch, the snap,\n// and the wobble as it settles are what make it read as goo.\nconst SPRING = { type: \"spring\", stiffness: 340, damping: 22, mass: 0.9 } as const;\nconst SNAP = { type: \"spring\", stiffness: 420, damping: 30 } as const;\n\nconst ITEMS = [\n  {\n    label: \"Circle\",\n    icon: <circle cx=\"8\" cy=\"8\" r=\"5\" />,\n  },\n  {\n    label: \"Square\",\n    icon: <rect x=\"3.25\" y=\"3.25\" width=\"9.5\" height=\"9.5\" rx=\"2\" />,\n  },\n  {\n    label: \"Triangle\",\n    icon: <path d=\"M8 3 13.5 12.5h-11z\" />,\n  },\n];\n\nfunction Glyph({ children, className }: { children: React.ReactNode; className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className={cn(\"size-4\", className)}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.4}\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      {children}\n    </svg>\n  );\n}\n\nexport function GooeyMenu({ className }: { className?: string }) {\n  const reduceMotion = useReducedMotion();\n  const play = usePreviewPlay();\n  const gooId = useId().replace(/:/g, \"\");\n  const menuId = useId();\n  const stage = useRef<HTMLDivElement>(null);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const [mode, setMode] = useState<Mode>(\"anchored\");\n  const [own, setOwn] = useState(false);\n  const [show, setShow] = useState(false);\n  const open = play ? show : own;\n  const [hoverItem, setHoverItem] = useState<number | null>(null);\n\n  // The menu panel, as a centre and a size: it starts as the button.\n  const px = useMotionValue(CX);\n  const py = useMotionValue(CY);\n  const pw = useMotionValue(BTN);\n  const ph = useMotionValue(BTN);\n  const pr = useMotionValue(BTN / 2);\n  // Its top-left corner, worked out from the centre and size.\n  const panelX = useTransform(() => px.get() - pw.get() / 2);\n  const panelY = useTransform(() => py.get() - ph.get() / 2);\n  // The three dial bubbles, as offsets from the button and a size.\n  const dx = [useMotionValue(0), useMotionValue(0), useMotionValue(0)];\n  const dy = [useMotionValue(0), useMotionValue(0), useMotionValue(0)];\n  const ds = [useMotionValue(0), useMotionValue(0), useMotionValue(0)];\n  // The drop that leans out of the button.\n  const leanX = useMotionValue(0);\n  const leanY = useMotionValue(0);\n  const lx = useSpring(leanX, { stiffness: 260, damping: 16, mass: 0.7 });\n  const ly = useSpring(leanY, { stiffness: 260, damping: 16, mass: 0.7 });\n\n  // Move every liquid part to where this mode and state want it.\n  useEffect(() => {\n    const go = (v: MotionValue<number>, to: number, t: object = SPRING) =>\n      reduceMotion ? v.jump(to) : animate(v, to, t);\n    const panelOpen = open && mode !== \"dial\";\n    if (panelOpen) {\n      const cy =\n        mode === \"anchored\"\n          ? CY - BTN / 2 - GAP - PANEL_H / 2\n          : CY + BTN / 2 - PANEL_H / 2;\n      go(px, CX);\n      go(py, cy);\n      go(pw, PANEL_W);\n      go(ph, PANEL_H);\n      go(pr, 18);\n    } else {\n      // Home into the button: the menu pours back into it.\n      go(px, CX, SNAP);\n      go(py, CY, SNAP);\n      go(pw, BTN, SNAP);\n      go(ph, BTN, SNAP);\n      go(pr, BTN / 2, SNAP);\n    }\n    DIAL_AT.forEach((at, i) => {\n      const out = open && mode === \"dial\";\n      // They leave one after another, and come home the other way round.\n      const delay = reduceMotion ? 0 : out ? i * 0.05 : (2 - i) * 0.03;\n      const t = { ...(out ? SPRING : SNAP), delay };\n      go(dx[i], out ? at.x : 0, t);\n      go(dy[i], out ? at.y : 0, t);\n      go(ds[i], out ? 1 : 0, t);\n    });\n    // dx, dy and ds are stable motion values.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, mode, reduceMotion, px, py, pw, ph, pr]);\n\n  // In the dial, the × reaches towards the option you're on.\n  useEffect(() => {\n    if (open && mode === \"dial\" && hoverItem !== null) {\n      leanX.set(DIAL_AT[hoverItem].x * 0.5);\n      leanY.set(DIAL_AT[hoverItem].y * 0.5);\n    }\n  }, [open, mode, hoverItem, leanX, leanY]);\n\n  // The card's show: open and close, then on to the next way of opening.\n  useEffect(() => {\n    if (!play) return;\n    let step = 0;\n    const id = setInterval(() => {\n      step += 1;\n      if (step % 2) {\n        setShow(true);\n      } else {\n        setShow(false);\n        setMode((m) => MODES[(MODES.findIndex((x) => x.id === m) + 1) % MODES.length].id);\n      }\n    }, 1300);\n    return () => {\n      clearInterval(id);\n      setShow(false);\n    };\n  }, [play]);\n\n  const close = (returnFocus: boolean) => {\n    setOwn(false);\n    setHoverItem(null);\n    if (returnFocus) trigger.current?.focus({ preventScroll: true });\n  };\n\n  // The drop leans towards the pointer, more the nearer it is.\n  const onMove = (e: React.PointerEvent) => {\n    if (play !== null || reduceMotion || e.pointerType === \"touch\") return;\n    if (open && mode === \"dial\" && hoverItem !== null) return;\n    const rect = e.currentTarget.getBoundingClientRect();\n    const k = W / rect.width;\n    const vx = (e.clientX - rect.left) * k - CX;\n    const vy = (e.clientY - rect.top) * k - CY;\n    const d = Math.hypot(vx, vy) || 1;\n    const pull = d < PULL ? Math.min(LEAN, d * 0.4) * (1 - d / PULL) ** 0.5 : 0;\n    leanX.set((vx / d) * pull);\n    leanY.set((vy / d) * pull);\n  };\n  const settle = () => {\n    leanX.set(0);\n    leanY.set(0);\n  };\n\n  const panelOpen = open && mode !== \"dial\";\n  const panelTop =\n    mode === \"anchored\" ? CY - BTN / 2 - GAP - PANEL_H : CY + BTN / 2 - PANEL_H;\n\n  return (\n    <div className={cn(\"flex w-[360px] max-w-full flex-col items-center gap-3\", className)}>\n      <div\n        ref={stage}\n        className=\"relative w-full select-none\"\n        style={{ height: H, maxWidth: W }}\n        onPointerMove={onMove}\n        onPointerLeave={settle}\n        onPointerDown={(e) => {\n          if (e.target === e.currentTarget && open) close(false);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\" && open) {\n            e.stopPropagation();\n            close(true);\n          }\n        }}\n      >\n        <svg aria-hidden className=\"absolute size-0\">\n          <filter id={gooId}>\n            <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"7\" result=\"blur\" />\n            <feColorMatrix\n              in=\"blur\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10\"\n              result=\"goo\"\n            />\n            <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n          </filter>\n        </svg>\n\n        {/* The liquid: everything that melts together, on one layer, with\n            its shadow cast by the melted shape rather than the parts. */}\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 [filter:drop-shadow(0_1px_1px_rgb(0_0_0/0.08))_drop-shadow(0_8px_16px_rgb(0_0_0/0.1))] dark:[filter:drop-shadow(0_0_0.5px_rgb(255_255_255/0.35))_drop-shadow(0_8px_16px_rgb(0_0_0/0.6))]\"\n        >\n          <div\n            className=\"absolute\"\n            style={{\n              left: \"50%\",\n              top: 0,\n              width: W,\n              height: H,\n              marginLeft: -W / 2,\n              filter: reduceMotion ? undefined : `url(#${gooId})`,\n            }}\n          >\n            <Blob x={CX} y={CY} size={BTN} />\n            <motion.div\n              className=\"absolute top-0 left-0 bg-background dark:bg-muted\"\n              style={{\n                x: lx,\n                y: ly,\n                left: CX - 13,\n                top: CY - 13,\n                width: 26,\n                height: 26,\n                borderRadius: 13,\n              }}\n            />\n            <motion.div\n              className=\"absolute top-0 left-0 bg-background dark:bg-muted\"\n              style={{ x: panelX, y: panelY, width: pw, height: ph, borderRadius: pr }}\n            />\n            {DIAL_AT.map((_, i) => (\n              <motion.div\n                key={i}\n                className=\"absolute bg-background dark:bg-muted\"\n                style={{\n                  left: CX - DIAL / 2,\n                  top: CY - DIAL / 2,\n                  width: DIAL,\n                  height: DIAL,\n                  borderRadius: DIAL / 2,\n                  x: dx[i],\n                  y: dy[i],\n                  scale: ds[i],\n                }}\n              />\n            ))}\n          </div>\n        </div>\n\n        {/* What you read and press, crisp, on top of the liquid. */}\n        <div className=\"absolute top-0 left-1/2\" style={{ width: W, height: H, marginLeft: -W / 2 }}>\n          <AnimatePresence>\n            {panelOpen && (\n              <motion.ul\n                id={menuId}\n                role=\"menu\"\n                aria-label=\"Shapes\"\n                className=\"absolute flex flex-col justify-center px-2\"\n                style={{ left: CX - PANEL_W / 2, top: panelTop, width: PANEL_W, height: PANEL_H }}\n                initial=\"hidden\"\n                animate=\"shown\"\n                exit=\"hidden\"\n                variants={{\n                  shown: { transition: { staggerChildren: 0.035, delayChildren: 0.1 } },\n                  hidden: { transition: { staggerChildren: 0.02, staggerDirection: -1 } },\n                }}\n              >\n                {ITEMS.map((item, i) => (\n                  <motion.li\n                    key={item.label}\n                    role=\"none\"\n                    variants={{\n                      hidden: { opacity: 0, y: 4, filter: \"blur(4px)\" },\n                      shown: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n                    }}\n                    transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n                  >\n                    <button\n                      type=\"button\"\n                      role=\"menuitem\"\n                      autoFocus={i === 0}\n                      onClick={() => close(true)}\n                      className=\"flex h-9 w-full items-center gap-2.5 rounded-[10px] px-2.5 text-left text-[14px] outline-hidden transition-[background-color] duration-150 ease-out hover:bg-foreground/[0.05] focus-visible:bg-foreground/[0.06] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n                    >\n                      <Glyph className=\"text-muted-foreground\">{item.icon}</Glyph>\n                      {item.label}\n                    </button>\n                  </motion.li>\n                ))}\n              </motion.ul>\n            )}\n          </AnimatePresence>\n\n          {DIAL_AT.map((_, i) => (\n            <motion.button\n              key={ITEMS[i].label}\n              type=\"button\"\n              aria-label={ITEMS[i].label}\n              tabIndex={open && mode === \"dial\" ? 0 : -1}\n              aria-hidden={!(open && mode === \"dial\")}\n              onPointerEnter={() => setHoverItem(i)}\n              onPointerLeave={() => {\n                setHoverItem(null);\n                settle();\n              }}\n              onFocus={() => setHoverItem(i)}\n              onBlur={() => setHoverItem(null)}\n              onClick={() => close(true)}\n              className=\"absolute grid place-items-center rounded-full outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n              style={{\n                left: CX - DIAL / 2,\n                top: CY - DIAL / 2,\n                width: DIAL,\n                height: DIAL,\n                x: dx[i],\n                y: dy[i],\n                opacity: ds[i],\n                pointerEvents: open && mode === \"dial\" ? \"auto\" : \"none\",\n              }}\n            >\n              <Glyph>{ITEMS[i].icon}</Glyph>\n            </motion.button>\n          ))}\n\n          <button\n            ref={trigger}\n            type=\"button\"\n            aria-label={open ? \"Close\" : \"Open shapes\"}\n            aria-haspopup=\"menu\"\n            aria-expanded={open}\n            aria-controls={panelOpen ? menuId : undefined}\n            onClick={() => (open ? close(false) : setOwn(true))}\n            className=\"absolute grid touch-manipulation place-items-center rounded-full outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.94]\"\n            style={{\n              left: CX - BTN / 2,\n              top: CY - BTN / 2,\n              width: BTN,\n              height: BTN,\n              // In morphing mode the button is under the open panel.\n              opacity: open && mode === \"morphing\" ? 0 : 1,\n              pointerEvents: open && mode === \"morphing\" ? \"none\" : \"auto\",\n              transition: \"opacity 150ms ease-out, scale 150ms ease-out\",\n            }}\n          >\n            <motion.span\n              className=\"grid place-items-center\"\n              initial={false}\n              animate={{ rotate: open ? 45 : 0 }}\n              transition={reduceMotion ? { duration: 0 } : SNAP}\n            >\n              <svg\n                viewBox=\"0 0 16 16\"\n                className=\"size-4\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={1.5}\n                strokeLinecap=\"round\"\n                aria-hidden\n              >\n                <path d=\"M8 3v10M3 8h10\" />\n              </svg>\n            </motion.span>\n          </button>\n        </div>\n      </div>\n\n      {/* Which way it opens. */}\n      <LayoutGroup id={`${gooId}-modes`}>\n        <div role=\"radiogroup\" aria-label=\"How it opens\" className=\"flex gap-1\">\n          {MODES.map((m) => {\n            const on = m.id === mode;\n            return (\n              <button\n                key={m.id}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={on}\n                tabIndex={play === null ? 0 : -1}\n                onClick={() => {\n                  setOwn(false);\n                  setHoverItem(null);\n                  setMode(m.id);\n                }}\n                className={cn(\n                  \"relative h-8 rounded-full px-3.5 text-[13px] outline-hidden transition-[color] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-foreground\",\n                  on ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                )}\n              >\n                {on && (\n                  <motion.span\n                    layoutId=\"mode\"\n                    aria-hidden\n                    className=\"absolute inset-0 rounded-full bg-background shadow-raised dark:bg-muted\"\n                    transition={reduceMotion ? { duration: 0 } : { type: \"spring\", visualDuration: 0.3, bounce: 0.15 }}\n                  />\n                )}\n                <span className=\"relative\">{m.label}</span>\n              </button>\n            );\n          })}\n        </div>\n      </LayoutGroup>\n    </div>\n  );\n}\n\nfunction Blob({ x, y, size }: { x: number; y: number; size: number }) {\n  return (\n    <div\n      className=\"absolute bg-background dark:bg-muted\"\n      style={{ left: x - size / 2, top: y - size / 2, width: size, height: size, borderRadius: size / 2 }}\n    />\n  );\n}\n\nexport default function GooeyMenuDemo() {\n  return <GooeyMenu />;\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/gooey-menu"}