{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "BubbleMenu-TS-TW", "type": "registry:block", "title": "BubbleMenu", "description": "Floating circular expanding menu with staggered item reveal.", "dependencies": [ "gsap" ], "files": [ { "path": "public/ts/tailwind/src/ts-tailwind/Components/BubbleMenu/BubbleMenu.tsx", "content": "import type { CSSProperties, ReactNode } from 'react';\r\nimport { useEffect, useRef, useState } from 'react';\r\nimport { gsap } from 'gsap';\r\n\r\ntype MenuItem = {\r\n label: string;\r\n href: string;\r\n ariaLabel?: string;\r\n rotation?: number;\r\n hoverStyles?: {\r\n bgColor?: string;\r\n textColor?: string;\r\n };\r\n};\r\n\r\nexport type BubbleMenuProps = {\r\n logo: ReactNode | string;\r\n onMenuClick?: (open: boolean) => void;\r\n className?: string;\r\n style?: CSSProperties;\r\n menuAriaLabel?: string;\r\n menuBg?: string;\r\n menuContentColor?: string;\r\n useFixedPosition?: boolean;\r\n items?: MenuItem[];\r\n animationEase?: string;\r\n animationDuration?: number;\r\n staggerDelay?: number;\r\n};\r\n\r\nconst DEFAULT_ITEMS: MenuItem[] = [\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}: BubbleMenuProps) {\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\r\n const containerClassName = [\r\n 'bubble-menu',\r\n useFixedPosition ? 'fixed' : 'absolute',\r\n 'left-0 right-0 top-8',\r\n 'flex items-center justify-between',\r\n 'gap-4 px-8',\r\n 'pointer-events-none',\r\n 'z-1001',\r\n className\r\n ]\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 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 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 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 window.addEventListener('resize', handleResize);\r\n return () => window.removeEventListener('resize', handleResize);\r\n }, [isMenuOpen, menuItems]);\r\n\r\n return (\r\n <>\r\n {/* Workaround for silly Tailwind capabilities */}\r\n \r\n\r\n \r\n\r\n {showOverlay && (\r\n \r\n \r\n {menuItems.map((item, idx) => (\r\n \r\n {\r\n if (el) bubblesRef.current[idx] = el;\r\n }}\r\n >\r\n {\r\n if (el) labelRefs.current[idx] = el;\r\n }}\r\n >\r\n {item.label}\r\n \r\n \r\n \r\n ))}\r\n \r\n \r\n )}\r\n \r\n );\r\n}\r\n", "type": "registry:component" } ] }