mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
12 KiB
JSON
1 line
12 KiB
JSON
{"name":"BubbleMenu","title":"BubbleMenu","description":"Floating circular expanding menu with staggered item reveal.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { gsap } from 'gsap';\nimport {\n computed,\n nextTick,\n onBeforeUnmount,\n onMounted,\n ref,\n useTemplateRef,\n watch,\n type CSSProperties,\n type VNode\n} from 'vue';\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: string | VNode;\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\nconst props = withDefaults(defineProps<BubbleMenuProps>(), {\n menuAriaLabel: 'Toggle menu',\n menuBg: '#fff',\n menuContentColor: '#111',\n useFixedPosition: false,\n animationEase: 'back.out(1.5)',\n animationDuration: 0.5,\n staggerDelay: 0.12\n});\n\nconst isMenuOpen = ref(false);\nconst showOverlay = ref(false);\n\nconst overlayRef = useTemplateRef('overlayRef');\nconst bubblesRef = ref<HTMLAnchorElement[]>([]);\nconst labelRefs = ref<HTMLSpanElement[]>([]);\n\nconst setBubbleRef = (el: HTMLAnchorElement | null, idx: number) => {\n if (el) {\n bubblesRef.value[idx] = el;\n }\n};\n\nconst setLabelsRef = (el: HTMLSpanElement | null, idx: number) => {\n if (el) {\n labelRefs.value[idx] = el;\n }\n};\n\nconst menuItems = computed(() => (props.items?.length ? props.items : DEFAULT_ITEMS));\n\nconst containerClassName = computed(() =>\n [\n 'bubble-menu',\n props.useFixedPosition ? 'fixed' : 'absolute',\n 'left-0 right-0 top-8',\n 'flex items-center justify-between',\n 'gap-4 px-8',\n 'pointer-events-none',\n 'z-[1001]',\n props.className\n ]\n .filter(Boolean)\n .join(' ')\n);\n\nconst handleToggle = () => {\n const nextState = !isMenuOpen.value;\n if (nextState) showOverlay.value = true;\n isMenuOpen.value = nextState;\n props.onMenuClick?.(nextState);\n};\n\nwatch(\n () => [isMenuOpen.value, showOverlay.value, props.animationEase, props.animationDuration, props.staggerDelay],\n async () => {\n await nextTick();\n\n const overlay = overlayRef.value;\n const bubbles = bubblesRef.value.filter(Boolean);\n const labels = labelRefs.value.filter(Boolean);\n if (!overlay || !bubbles.length) return;\n\n if (isMenuOpen.value) {\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 * props.staggerDelay + gsap.utils.random(-0.05, 0.05);\n const tl = gsap.timeline({ delay });\n tl.to(bubble, {\n scale: 1,\n duration: props.animationDuration,\n ease: props.animationEase\n });\n if (labels[i]) {\n tl.to(\n labels[i],\n {\n y: 0,\n autoAlpha: 1,\n duration: props.animationDuration,\n ease: 'power3.out'\n },\n '-=' + props.animationDuration * 0.9\n );\n }\n });\n } else if (showOverlay.value) {\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 showOverlay.value = false;\n }\n });\n }\n },\n { deep: true }\n);\n\nlet cleanupResizeListener: () => void;\nconst handleResize = () => {\n if (isMenuOpen.value) {\n const bubbles = bubblesRef.value.filter(Boolean);\n const isDesktop = window.innerWidth >= 900;\n bubbles.forEach((bubble, i) => {\n const item = menuItems.value[i];\n if (bubble && item) {\n const rotation = isDesktop ? (item.rotation ?? 0) : 0;\n gsap.set(bubble, { rotation });\n }\n });\n }\n\n window.addEventListener('resize', handleResize);\n cleanupResizeListener = () => window.removeEventListener('resize', handleResize);\n};\n\nonMounted(() => {\n handleResize();\n});\n\nonBeforeUnmount(() => {\n cleanupResizeListener?.();\n});\n\nwatch(\n () => [isMenuOpen.value, menuItems.value],\n () => {\n cleanupResizeListener?.();\n handleResize();\n },\n { deep: true }\n);\n</script>\n\n<template>\n <nav :class=\"containerClassName\" :style=\"style\" aria-label=\"Main navigation\">\n <div\n :class=\"[\n 'bubble logo-bubble',\n 'inline-flex items-center justify-center',\n 'rounded-full',\n 'bg-white',\n 'shadow-[0_4px_16px_rgba(0,0,0,0.12)]',\n 'pointer-events-auto',\n 'h-12 md:h-14',\n 'px-4 md:px-8',\n 'gap-2',\n 'will-change-transform'\n ]\"\n aria-label=\"Logo\"\n :style=\"{\n background: menuBg,\n minHeight: '48px',\n borderRadius: '9999px'\n }\"\n >\n <span\n :class=\"['logo-content', 'inline-flex items-center justify-center', 'w-[120px] h-full']\"\n :style=\"{ '--logo-max-height': '60%', '--logo-max-width': '100%' }\"\n >\n <img\n v-if=\"typeof logo === 'string'\"\n :src=\"logo as string\"\n alt=\"Logo\"\n class=\"block max-w-full max-h-[60%] object-contain bubble-logo\"\n />\n <template v-else>\n <component :is=\"logo\" />\n </template>\n </span>\n </div>\n\n <button\n type=\"button\"\n :class=\"[\n 'bubble toggle-bubble menu-btn',\n isMenuOpen ? 'open' : '',\n 'inline-flex flex-col items-center justify-center',\n 'rounded-full',\n 'bg-white',\n 'shadow-[0_4px_16px_rgba(0,0,0,0.12)]',\n 'pointer-events-auto',\n 'w-12 h-12 md:w-14 md:h-14',\n 'border-0 cursor-pointer p-0',\n 'will-change-transform'\n ]\"\n @click=\"handleToggle\"\n :aria-label=\"menuAriaLabel\"\n :aria-pressed=\"isMenuOpen\"\n :style=\"{ background: menuBg }\"\n >\n <span\n class=\"block mx-auto rounded-[2px] menu-line\"\n :style=\"{\n width: '26px',\n height: '2px',\n background: menuContentColor,\n transform: isMenuOpen ? 'translateY(4px) rotate(45deg)' : 'none'\n }\"\n />\n <span\n class=\"block mx-auto rounded-[2px] menu-line short\"\n :style=\"{\n marginTop: '6px',\n width: '26px',\n height: '2px',\n background: menuContentColor,\n transform: isMenuOpen ? 'translateY(-4px) rotate(-45deg)' : 'none'\n }\"\n />\n </button>\n </nav>\n\n <div\n v-if=\"showOverlay\"\n ref=\"overlayRef\"\n :class=\"[\n 'bubble-menu-items',\n useFixedPosition ? 'fixed' : 'absolute',\n 'inset-0',\n 'flex items-center justify-center',\n 'pointer-events-none',\n 'z-[1000]'\n ]\"\n :aria-hidden=\"!isMenuOpen\"\n >\n <ul\n :class=\"[\n 'pill-list',\n 'list-none m-0 px-6',\n 'w-full max-w-[1600px] mx-auto',\n 'flex flex-wrap',\n 'gap-x-0 gap-y-1',\n 'pointer-events-auto'\n ]\"\n role=\"menu\"\n aria-label=\"Menu links\"\n >\n <li\n v-for=\"(item, idx) in menuItems\"\n :key=\"idx\"\n role=\"none\"\n :class=\"['pill-col', 'flex justify-center items-stretch', '[flex:0_0_calc(100%/3)]', 'box-border']\"\n >\n <a\n role=\"menuitem\"\n :href=\"item.href\"\n :aria-label=\"item.ariaLabel || item.label\"\n :class=\"[\n 'pill-link',\n 'w-full',\n 'rounded-[999px]',\n 'no-underline',\n 'bg-white',\n 'text-inherit',\n 'shadow-[0_4px_14px_rgba(0,0,0,0.10)]',\n 'flex items-center justify-center',\n 'relative',\n 'transition-[background,color] duration-300 ease-in-out',\n 'box-border',\n 'whitespace-nowrap overflow-hidden'\n ]\"\n :style=\"{\n '--item-rot': `${item.rotation ?? 0}deg`,\n '--pill-bg': menuBg,\n '--pill-color': menuContentColor,\n '--hover-bg': item.hoverStyles?.bgColor || '#f3f4f6',\n '--hover-color': item.hoverStyles?.textColor || menuContentColor,\n background: 'var(--pill-bg)',\n color: 'var(--pill-color)',\n minHeight: 'var(--pill-min-h, 160px)',\n padding: 'clamp(1.5rem, 3vw, 8rem) 0',\n fontSize: 'clamp(1.5rem, 4vw, 4rem)',\n fontWeight: 400,\n lineHeight: 0,\n willChange: 'transform',\n height: '10px'\n }\"\n :ref=\"el => setBubbleRef(el as HTMLAnchorElement | null, idx)\"\n >\n <span\n class=\"inline-block pill-label\"\n :style=\"{\n willChange: 'transform, opacity',\n height: '1.2em',\n lineHeight: 1.2\n }\"\n :ref=\"el => setLabelsRef(el as HTMLSpanElement | null, idx)\"\n >\n {{ item.label }}\n </span>\n </a>\n </li>\n </ul>\n </div>\n</template>\n\n<style scoped>\n.bubble-menu .menu-line {\n transition:\n transform 0.3s ease,\n opacity 0.3s ease;\n transform-origin: center;\n}\n.bubble-menu-items .pill-list .pill-col:nth-child(4):nth-last-child(2) {\n margin-left: calc(100% / 6);\n}\n.bubble-menu-items .pill-list .pill-col:nth-child(4):last-child {\n margin-left: calc(100% / 3);\n}\n@media (min-width: 900px) {\n .bubble-menu-items .pill-link {\n transform: rotate(var(--item-rot));\n }\n .bubble-menu-items .pill-link:hover {\n transform: rotate(var(--item-rot)) scale(1.06);\n background: var(--hover-bg) !important;\n color: var(--hover-color) !important;\n }\n .bubble-menu-items .pill-link:active {\n transform: rotate(var(--item-rot)) scale(0.94);\n }\n}\n@media (max-width: 899px) {\n .bubble-menu-items {\n padding-top: 120px;\n align-items: flex-start;\n }\n .bubble-menu-items .pill-list {\n row-gap: 16px;\n }\n .bubble-menu-items .pill-list .pill-col {\n flex: 0 0 100% !important;\n margin-left: 0 !important;\n overflow: visible;\n }\n .bubble-menu-items .pill-link {\n font-size: clamp(1.2rem, 3vw, 4rem);\n padding: clamp(1rem, 2vw, 2rem) 0;\n min-height: 80px !important;\n }\n .bubble-menu-items .pill-link:hover {\n transform: scale(1.06);\n background: var(--hover-bg);\n color: var(--hover-color);\n }\n .bubble-menu-items .pill-link:active {\n transform: scale(0.94);\n }\n}\n</style>\n","path":"BubbleMenu/BubbleMenu.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["Components"]} |