{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "BubbleMenu-TS-CSS", "type": "registry:block", "title": "BubbleMenu", "description": "Floating circular expanding menu with staggered item reveal.", "dependencies": [ "gsap" ], "files": [ { "path": "public/ts/default/src/ts-default/Components/BubbleMenu/BubbleMenu.tsx", "content": "import type { CSSProperties, ReactNode } from 'react';\nimport { useState, useRef, useEffect } from 'react';\nimport { gsap } from 'gsap';\n\nimport './BubbleMenu.css';\n\ntype MenuItem = {\n label: string;\n href: string;\n ariaLabel?: string;\n rotation?: number;\n hoverStyles?: {\n bgColor?: string;\n textColor?: string;\n };\n};\n\nexport type BubbleMenuProps = {\n logo: ReactNode | string;\n onMenuClick?: (open: boolean) => void;\n className?: string;\n style?: CSSProperties;\n menuAriaLabel?: string;\n menuBg?: string;\n menuContentColor?: string;\n useFixedPosition?: boolean;\n items?: MenuItem[];\n animationEase?: string;\n animationDuration?: number;\n staggerDelay?: number;\n};\n\nconst DEFAULT_ITEMS: MenuItem[] = [\n {\n label: 'home',\n href: '#',\n ariaLabel: 'Home',\n rotation: -8,\n hoverStyles: { bgColor: '#3b82f6', textColor: '#ffffff' }\n },\n {\n label: 'about',\n href: '#',\n ariaLabel: 'About',\n rotation: 8,\n hoverStyles: { bgColor: '#10b981', textColor: '#ffffff' }\n },\n {\n label: 'projects',\n href: '#',\n ariaLabel: 'Documentation',\n rotation: 8,\n hoverStyles: { bgColor: '#f59e0b', textColor: '#ffffff' }\n },\n {\n label: 'blog',\n href: '#',\n ariaLabel: 'Blog',\n rotation: 8,\n hoverStyles: { bgColor: '#ef4444', textColor: '#ffffff' }\n },\n {\n label: 'contact',\n href: '#',\n ariaLabel: 'Contact',\n rotation: -8,\n hoverStyles: { bgColor: '#8b5cf6', textColor: '#ffffff' }\n }\n];\n\nexport default function BubbleMenu({\n logo,\n onMenuClick,\n className,\n style,\n menuAriaLabel = 'Toggle menu',\n menuBg = '#fff',\n menuContentColor = '#111',\n useFixedPosition = false,\n items,\n animationEase = 'back.out(1.5)',\n animationDuration = 0.5,\n staggerDelay = 0.12\n}: BubbleMenuProps) {\n const [isMenuOpen, setIsMenuOpen] = useState(false);\n const [showOverlay, setShowOverlay] = useState(false);\n\n const overlayRef = useRef(null);\n const bubblesRef = useRef([]);\n const labelRefs = useRef([]);\n\n const menuItems = items?.length ? items : DEFAULT_ITEMS;\n const containerClassName = ['bubble-menu', useFixedPosition ? 'fixed' : 'absolute', className]\n .filter(Boolean)\n .join(' ');\n\n const handleToggle = () => {\n const nextState = !isMenuOpen;\n if (nextState) setShowOverlay(true);\n setIsMenuOpen(nextState);\n onMenuClick?.(nextState);\n };\n\n useEffect(() => {\n const overlay = overlayRef.current;\n const bubbles = bubblesRef.current.filter(Boolean);\n const labels = labelRefs.current.filter(Boolean);\n\n if (!overlay || !bubbles.length) return;\n\n if (isMenuOpen) {\n gsap.set(overlay, { display: 'flex' });\n gsap.killTweensOf([...bubbles, ...labels]);\n gsap.set(bubbles, { scale: 0, transformOrigin: '50% 50%' });\n gsap.set(labels, { y: 24, autoAlpha: 0 });\n\n bubbles.forEach((bubble, i) => {\n const delay = i * staggerDelay + gsap.utils.random(-0.05, 0.05);\n const tl = gsap.timeline({ delay });\n\n tl.to(bubble, {\n scale: 1,\n duration: animationDuration,\n ease: animationEase\n });\n if (labels[i]) {\n tl.to(\n labels[i],\n {\n y: 0,\n autoAlpha: 1,\n duration: animationDuration,\n ease: 'power3.out'\n },\n `-=${animationDuration * 0.9}`\n );\n }\n });\n } else if (showOverlay) {\n gsap.killTweensOf([...bubbles, ...labels]);\n gsap.to(labels, {\n y: 24,\n autoAlpha: 0,\n duration: 0.2,\n ease: 'power3.in'\n });\n gsap.to(bubbles, {\n scale: 0,\n duration: 0.2,\n ease: 'power3.in',\n onComplete: () => {\n gsap.set(overlay, { display: 'none' });\n setShowOverlay(false);\n }\n });\n }\n }, [isMenuOpen, showOverlay, animationEase, animationDuration, staggerDelay]);\n\n useEffect(() => {\n const handleResize = () => {\n if (isMenuOpen) {\n const bubbles = bubblesRef.current.filter(Boolean);\n const isDesktop = window.innerWidth >= 900;\n\n bubbles.forEach((bubble, i) => {\n const item = menuItems[i];\n if (bubble && item) {\n const rotation = isDesktop ? (item.rotation ?? 0) : 0;\n gsap.set(bubble, { rotation });\n }\n });\n }\n };\n\n window.addEventListener('resize', handleResize);\n return () => window.removeEventListener('resize', handleResize);\n }, [isMenuOpen, menuItems]);\n\n return (\n <>\n \n {showOverlay && (\n \n
    \n {menuItems.map((item, idx) => (\n
  • \n {\n if (el) bubblesRef.current[idx] = el;\n }}\n >\n {\n if (el) labelRefs.current[idx] = el;\n }}\n >\n {item.label}\n \n \n
  • \n ))}\n
\n \n )}\n \n );\n}\n", "type": "registry:component" }, { "path": "public/ts/default/src/ts-default/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" } ] }