{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "BubbleMenu-JS-CSS", "type": "registry:block", "title": "BubbleMenu", "description": "Floating circular expanding menu with staggered item reveal.", "dependencies": [ "gsap" ], "files": [ { "path": "public/default/src/content/Components/BubbleMenu/BubbleMenu.jsx", "content": "import { useState, useRef, useEffect } from 'react';\r\nimport { gsap } from 'gsap';\r\n\r\nimport './BubbleMenu.css';\r\n\r\nconst DEFAULT_ITEMS = [\r\n {\r\n label: 'home',\r\n href: '#',\r\n ariaLabel: 'Home',\r\n rotation: -8,\r\n hoverStyles: { bgColor: '#3b82f6', textColor: '#ffffff' }\r\n },\r\n {\r\n label: 'about',\r\n href: '#',\r\n ariaLabel: 'About',\r\n rotation: 8,\r\n hoverStyles: { bgColor: '#10b981', textColor: '#ffffff' }\r\n },\r\n {\r\n label: 'projects',\r\n href: '#',\r\n ariaLabel: 'Documentation',\r\n rotation: 8,\r\n hoverStyles: { bgColor: '#f59e0b', textColor: '#ffffff' }\r\n },\r\n {\r\n label: 'blog',\r\n href: '#',\r\n ariaLabel: 'Blog',\r\n rotation: 8,\r\n hoverStyles: { bgColor: '#ef4444', textColor: '#ffffff' }\r\n },\r\n {\r\n label: 'contact',\r\n href: '#',\r\n ariaLabel: 'Contact',\r\n rotation: -8,\r\n hoverStyles: { bgColor: '#8b5cf6', textColor: '#ffffff' }\r\n }\r\n];\r\n\r\nexport default function BubbleMenu({\r\n logo,\r\n onMenuClick,\r\n className,\r\n style,\r\n menuAriaLabel = 'Toggle menu',\r\n menuBg = '#fff',\r\n menuContentColor = '#111',\r\n useFixedPosition = false,\r\n items,\r\n animationEase = 'back.out(1.5)',\r\n animationDuration = 0.5,\r\n staggerDelay = 0.12\r\n}) {\r\n const [isMenuOpen, setIsMenuOpen] = useState(false);\r\n const [showOverlay, setShowOverlay] = useState(false);\r\n\r\n const overlayRef = useRef(null);\r\n const bubblesRef = useRef([]);\r\n const labelRefs = useRef([]);\r\n\r\n const menuItems = items?.length ? items : DEFAULT_ITEMS;\r\n const containerClassName = ['bubble-menu', useFixedPosition ? 'fixed' : 'absolute', className]\r\n .filter(Boolean)\r\n .join(' ');\r\n\r\n const handleToggle = () => {\r\n const nextState = !isMenuOpen;\r\n if (nextState) setShowOverlay(true);\r\n setIsMenuOpen(nextState);\r\n onMenuClick?.(nextState);\r\n };\r\n\r\n useEffect(() => {\r\n const overlay = overlayRef.current;\r\n const bubbles = bubblesRef.current.filter(Boolean);\r\n const labels = labelRefs.current.filter(Boolean);\r\n\r\n if (!overlay || !bubbles.length) return;\r\n\r\n if (isMenuOpen) {\r\n gsap.set(overlay, { display: 'flex' });\r\n gsap.killTweensOf([...bubbles, ...labels]);\r\n gsap.set(bubbles, { scale: 0, transformOrigin: '50% 50%' });\r\n gsap.set(labels, { y: 24, autoAlpha: 0 });\r\n\r\n bubbles.forEach((bubble, i) => {\r\n const delay = i * staggerDelay + gsap.utils.random(-0.05, 0.05);\r\n const tl = gsap.timeline({ delay });\r\n\r\n tl.to(bubble, {\r\n scale: 1,\r\n duration: animationDuration,\r\n ease: animationEase\r\n });\r\n if (labels[i]) {\r\n tl.to(\r\n labels[i],\r\n {\r\n y: 0,\r\n autoAlpha: 1,\r\n duration: animationDuration,\r\n ease: 'power3.out'\r\n },\r\n `-=${animationDuration * 0.9}`\r\n );\r\n }\r\n });\r\n } else if (showOverlay) {\r\n gsap.killTweensOf([...bubbles, ...labels]);\r\n gsap.to(labels, {\r\n y: 24,\r\n autoAlpha: 0,\r\n duration: 0.2,\r\n ease: 'power3.in'\r\n });\r\n gsap.to(bubbles, {\r\n scale: 0,\r\n duration: 0.2,\r\n ease: 'power3.in',\r\n onComplete: () => {\r\n gsap.set(overlay, { display: 'none' });\r\n setShowOverlay(false);\r\n }\r\n });\r\n }\r\n }, [isMenuOpen, showOverlay, animationEase, animationDuration, staggerDelay]);\r\n\r\n useEffect(() => {\r\n const handleResize = () => {\r\n if (isMenuOpen) {\r\n const bubbles = bubblesRef.current.filter(Boolean);\r\n const isDesktop = window.innerWidth >= 900;\r\n\r\n bubbles.forEach((bubble, i) => {\r\n const item = menuItems[i];\r\n if (bubble && item) {\r\n const rotation = isDesktop ? (item.rotation ?? 0) : 0;\r\n gsap.set(bubble, { rotation });\r\n }\r\n });\r\n }\r\n };\r\n\r\n window.addEventListener('resize', handleResize);\r\n return () => window.removeEventListener('resize', handleResize);\r\n }, [isMenuOpen, menuItems]);\r\n\r\n return (\r\n <>\r\n \r\n {showOverlay && (\r\n \r\n \r\n \r\n )}\r\n \r\n );\r\n}\r\n", "type": "registry:component" }, { "path": "public/default/src/content/Components/BubbleMenu/BubbleMenu.css", "content": ".bubble-menu {\r\n left: 0;\r\n right: 0;\r\n top: 2em;\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 16px;\r\n padding: 0 2em;\r\n pointer-events: none;\r\n z-index: 99;\r\n}\r\n\r\n.bubble-menu.fixed {\r\n position: fixed;\r\n}\r\n\r\n.bubble-menu.absolute {\r\n position: absolute;\r\n}\r\n\r\n.bubble-menu .bubble {\r\n --bubble-size: 48px;\r\n width: var(--bubble-size);\r\n height: var(--bubble-size);\r\n border-radius: 50%;\r\n background: #fff;\r\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n pointer-events: auto;\r\n}\r\n\r\n.bubble-menu .logo-bubble,\r\n.bubble-menu .toggle-bubble {\r\n will-change: transform;\r\n}\r\n\r\n.bubble-menu .logo-bubble {\r\n width: auto;\r\n min-height: var(--bubble-size);\r\n height: var(--bubble-size);\r\n padding: 0 16px;\r\n border-radius: calc(var(--bubble-size) / 2);\r\n gap: 8px;\r\n}\r\n\r\n.bubble-menu .toggle-bubble {\r\n width: var(--bubble-size);\r\n height: var(--bubble-size);\r\n}\r\n\r\n.bubble-menu .bubble-logo {\r\n max-height: 60%;\r\n max-width: 100%;\r\n object-fit: contain;\r\n display: block;\r\n}\r\n\r\n.bubble-menu .logo-content {\r\n --logo-max-height: 60%;\r\n --logo-max-width: 100%;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 120px;\r\n height: 100%;\r\n}\r\n\r\n.bubble-menu .logo-content > .bubble-logo,\r\n.bubble-menu .logo-content > img,\r\n.bubble-menu .logo-content > svg {\r\n max-height: var(--logo-max-height);\r\n max-width: var(--logo-max-width);\r\n}\r\n\r\n.bubble-menu .menu-btn {\r\n border: none;\r\n background: #fff;\r\n cursor: pointer;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n padding: 0;\r\n}\r\n\r\n.bubble-menu .menu-line {\r\n width: 26px;\r\n height: 2px;\r\n background: #111;\r\n border-radius: 2px;\r\n display: block;\r\n margin: 0 auto;\r\n transition:\r\n transform 0.3s ease,\r\n opacity 0.3s ease;\r\n transform-origin: center;\r\n}\r\n\r\n.bubble-menu .menu-line + .menu-line {\r\n margin-top: 6px;\r\n}\r\n\r\n.bubble-menu .menu-btn.open .menu-line:first-child {\r\n transform: translateY(4px) rotate(45deg);\r\n}\r\n\r\n.bubble-menu .menu-btn.open .menu-line:last-child {\r\n transform: translateY(-4px) rotate(-45deg);\r\n}\r\n\r\n@media (min-width: 768px) {\r\n .bubble-menu .bubble {\r\n --bubble-size: 56px;\r\n }\r\n\r\n .bubble-menu .logo-bubble {\r\n padding: 0 16px;\r\n }\r\n}\r\n\r\n.bubble-menu-items {\r\n position: absolute;\r\n inset: 0;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n pointer-events: none;\r\n z-index: 98;\r\n}\r\n\r\n.bubble-menu-items.fixed {\r\n position: fixed;\r\n}\r\n\r\n.bubble-menu-items.absolute {\r\n position: absolute;\r\n}\r\n\r\n.bubble-menu-items .pill-list {\r\n list-style: none;\r\n margin: 0;\r\n padding: 0 24px;\r\n display: flex;\r\n flex-wrap: wrap;\r\n gap: 0;\r\n row-gap: 4px;\r\n width: 100%;\r\n max-width: 1600px;\r\n margin-left: auto;\r\n margin-right: auto;\r\n pointer-events: auto;\r\n justify-content: stretch;\r\n}\r\n\r\n.bubble-menu-items .pill-list .pill-spacer {\r\n width: 100%;\r\n height: 0;\r\n pointer-events: none;\r\n}\r\n\r\n.bubble-menu-items .pill-list .pill-col {\r\n display: flex;\r\n justify-content: center;\r\n align-items: stretch;\r\n flex: 0 0 calc(100% / 3);\r\n box-sizing: border-box;\r\n}\r\n\r\n.bubble-menu-items .pill-list .pill-col:nth-child(4):nth-last-child(2) {\r\n margin-left: calc(100% / 6);\r\n}\r\n\r\n.bubble-menu-items .pill-list .pill-col:nth-child(4):last-child {\r\n margin-left: calc(100% / 3);\r\n}\r\n\r\n.bubble-menu-items .pill-link {\r\n --pill-bg: #ffffff;\r\n --pill-color: #111;\r\n --pill-border: rgba(0, 0, 0, 0.12);\r\n --item-rot: 0deg;\r\n --pill-min-h: 160px;\r\n --hover-bg: #f3f4f6;\r\n --hover-color: #111;\r\n width: 100%;\r\n min-height: var(--pill-min-h);\r\n padding: clamp(1.5rem, 3vw, 8rem) 0;\r\n font-size: clamp(1.5rem, 4vw, 4rem);\r\n font-weight: 400;\r\n line-height: 0;\r\n border-radius: 999px;\r\n background: var(--pill-bg);\r\n color: var(--pill-color);\r\n text-decoration: none;\r\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n position: relative;\r\n transition:\r\n background 0.3s ease,\r\n color 0.3s ease;\r\n will-change: transform;\r\n box-sizing: border-box;\r\n white-space: nowrap;\r\n overflow: hidden;\r\n height: 10px;\r\n}\r\n\r\n@media (min-width: 900px) {\r\n .bubble-menu-items .pill-link {\r\n transform: rotate(var(--item-rot));\r\n }\r\n\r\n .bubble-menu-items .pill-link:hover {\r\n transform: rotate(var(--item-rot)) scale(1.06);\r\n background: var(--hover-bg);\r\n color: var(--hover-color);\r\n }\r\n\r\n .bubble-menu-items .pill-link:active {\r\n transform: rotate(var(--item-rot)) scale(0.94);\r\n }\r\n}\r\n\r\n.bubble-menu-items .pill-link .pill-label {\r\n display: inline-block;\r\n will-change: transform, opacity;\r\n height: 1.2em;\r\n line-height: 1.2;\r\n}\r\n\r\n@media (max-width: 899px) {\r\n .bubble-menu-items {\r\n padding-top: 0px;\r\n align-items: flex-start;\r\n padding-top: 120px;\r\n }\r\n\r\n .bubble-menu-items .pill-list {\r\n row-gap: 16px;\r\n }\r\n\r\n .bubble-menu-items .pill-list .pill-col {\r\n flex: 0 0 100%;\r\n margin-left: 0 !important;\r\n overflow: visible;\r\n }\r\n\r\n .bubble-menu-items .pill-link {\r\n font-size: clamp(1.2rem, 3vw, 4rem);\r\n padding: clamp(1rem, 2vw, 2rem) 0;\r\n min-height: 80px;\r\n }\r\n\r\n .bubble-menu-items .pill-link:hover {\r\n transform: scale(1.06);\r\n background: var(--hover-bg);\r\n color: var(--hover-color);\r\n }\r\n\r\n .bubble-menu-items .pill-link:active {\r\n transform: scale(0.94);\r\n }\r\n}\r\n", "type": "registry:item" } ] }