mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
14 KiB
JSON
1 line
14 KiB
JSON
{"name":"PillNav","title":"PillNav","description":"Minimal pill nav with sliding active highlight + smooth easing.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { gsap } from 'gsap';\nimport { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';\n\ntype PillNavItem = {\n label: string;\n href?: string;\n ariaLabel?: string;\n};\n\ninterface PillNavProps {\n logo: string;\n logoAlt?: string;\n items: PillNavItem[];\n activeHref?: string;\n className?: string;\n ease?: string;\n baseColor?: string;\n pillColor?: string;\n hoveredPillTextColor?: string;\n pillTextColor?: string;\n onMobileMenuClick?: () => void;\n initialLoadAnimation?: boolean;\n}\n\nconst props = withDefaults(defineProps<PillNavProps>(), {\n logoAlt: 'Logo',\n className: '',\n ease: 'power3.easeOut',\n baseColor: '#fff',\n pillColor: '#060010',\n hoveredPillTextColor: '#060010',\n initialLoadAnimation: true\n});\n\nconst resolvedPillTextColor = props.pillTextColor ?? props.baseColor;\nconst isMobileMenuOpen = ref(false);\nconst circleRefs = ref<Array<HTMLSpanElement | null>>([]);\nconst tlRefs = ref<Array<gsap.core.Timeline | null>>([]);\nconst activeTweenRefs = ref<Array<gsap.core.Tween | null>>([]);\nconst logoImgRef = useTemplateRef('logoImgRef');\nconst logoTweenRef = ref<gsap.core.Tween | null>(null);\nconst hamburgerRef = useTemplateRef('hamburgerRef');\nconst mobileMenuRef = useTemplateRef('mobileMenuRef');\nconst navItemsRef = useTemplateRef('navItemsRef');\nconst logoRef = useTemplateRef('logoRef');\n\nwatch(\n () => props.items,\n items => {\n circleRefs.value = new Array(items.length).fill(null);\n tlRefs.value = new Array(items.length).fill(null);\n activeTweenRefs.value = new Array(items.length).fill(null);\n },\n { immediate: true }\n);\n\nconst layout = () => {\n circleRefs.value.forEach(circle => {\n if (!circle?.parentElement) return;\n\n const pill = circle.parentElement as HTMLElement;\n const rect = pill.getBoundingClientRect();\n const { width: w, height: h } = rect;\n const R = ((w * w) / 4 + h * h) / (2 * h);\n const D = Math.ceil(2 * R) + 2;\n const delta = Math.ceil(R - Math.sqrt(Math.max(0, R * R - (w * w) / 4))) + 1;\n const originY = D - delta;\n\n circle.style.width = `${D}px`;\n circle.style.height = `${D}px`;\n circle.style.bottom = `-${delta}px`;\n\n gsap.set(circle, {\n xPercent: -50,\n scale: 0,\n transformOrigin: `50% ${originY}px`\n });\n\n const label = pill.querySelector<HTMLElement>('.pill-label');\n const white = pill.querySelector<HTMLElement>('.pill-label-hover');\n\n if (label) gsap.set(label, { y: 0 });\n if (white) gsap.set(white, { y: h + 12, opacity: 0 });\n\n const index = circleRefs.value.indexOf(circle);\n if (index === -1) return;\n\n tlRefs.value[index]?.kill();\n const tl = gsap.timeline({ paused: true });\n\n tl.to(circle, { scale: 1.2, xPercent: -50, duration: 2, ease: props.ease, overwrite: 'auto' }, 0);\n\n if (label) {\n tl.to(label, { y: -(h + 8), duration: 2, ease: props.ease, overwrite: 'auto' }, 0);\n }\n\n if (white) {\n gsap.set(white, { y: Math.ceil(h + 100), opacity: 0 });\n tl.to(white, { y: 0, opacity: 1, duration: 2, ease: props.ease, overwrite: 'auto' }, 0);\n }\n\n tlRefs.value[index] = tl;\n });\n};\n\nconst onResize = () => layout();\n\nonMounted(() => {\n layout();\n\n window.addEventListener('resize', onResize);\n\n if (document.fonts) {\n document.fonts.ready.then(layout).catch(() => {});\n }\n\n const menu = mobileMenuRef.value;\n if (menu) {\n gsap.set(menu, { visibility: 'hidden', opacity: 0, scaleY: 1, y: 0 });\n }\n\n if (props.initialLoadAnimation) {\n const logo = logoRef.value;\n const navItems = navItemsRef.value;\n\n if (logo) {\n gsap.set(logo, { scale: 0 });\n gsap.to(logo, {\n scale: 1,\n duration: 0.6,\n ease: props.ease\n });\n }\n\n if (navItems) {\n gsap.set(navItems, { width: 0, overflow: 'hidden' });\n gsap.to(navItems, {\n width: 'auto',\n duration: 0.6,\n ease: props.ease\n });\n }\n }\n});\n\nonBeforeUnmount(() => {\n window.removeEventListener('resize', onResize);\n});\n\nwatch(\n () => [props.items, props.ease, props.initialLoadAnimation],\n () => {\n layout();\n },\n { deep: true }\n);\n\nconst handleEnter = (i: number) => {\n const tl = tlRefs.value[i];\n if (!tl) return;\n activeTweenRefs.value[i]?.kill();\n activeTweenRefs.value[i] = tl.tweenTo(tl.duration(), {\n duration: 0.3,\n ease: props.ease,\n overwrite: 'auto'\n });\n};\n\nconst handleLeave = (i: number) => {\n const tl = tlRefs.value[i];\n if (!tl) return;\n activeTweenRefs.value[i]?.kill();\n activeTweenRefs.value[i] = tl.tweenTo(0, {\n duration: 0.2,\n ease: props.ease,\n overwrite: 'auto'\n });\n};\n\nconst handleLogoEnter = () => {\n const img = logoImgRef.value;\n if (!img) return;\n logoTweenRef.value?.kill();\n gsap.set(img, { rotate: 0 });\n logoTweenRef.value = gsap.to(img, {\n rotate: 360,\n duration: 0.2,\n ease: props.ease,\n overwrite: 'auto'\n });\n};\n\nconst toggleMobileMenu = () => {\n const newState = !isMobileMenuOpen.value;\n isMobileMenuOpen.value = newState;\n\n const hamburger = hamburgerRef.value;\n const menu = mobileMenuRef.value;\n\n if (hamburger) {\n const lines = hamburger.querySelectorAll('.hamburger-line');\n if (newState) {\n gsap.to(lines[0], { rotation: 45, y: 3, duration: 0.3, ease: props.ease });\n gsap.to(lines[1], { rotation: -45, y: -3, duration: 0.3, ease: props.ease });\n } else {\n gsap.to(lines[0], { rotation: 0, y: 0, duration: 0.3, ease: props.ease });\n gsap.to(lines[1], { rotation: 0, y: 0, duration: 0.3, ease: props.ease });\n }\n }\n\n if (menu) {\n if (newState) {\n gsap.set(menu, { visibility: 'visible' });\n gsap.fromTo(\n menu,\n { opacity: 0, y: 10, scaleY: 1 },\n {\n opacity: 1,\n y: 0,\n scaleY: 1,\n duration: 0.3,\n ease: props.ease,\n transformOrigin: 'top center'\n }\n );\n } else {\n gsap.to(menu, {\n opacity: 0,\n y: 10,\n scaleY: 1,\n duration: 0.2,\n ease: props.ease,\n transformOrigin: 'top center',\n onComplete: () => {\n gsap.set(menu, { visibility: 'hidden' });\n }\n });\n }\n }\n\n props.onMobileMenuClick?.();\n};\n\nconst isExternalLink = (href: string) =>\n href.startsWith('http://') ||\n href.startsWith('https://') ||\n href.startsWith('//') ||\n href.startsWith('mailto:') ||\n href.startsWith('tel:') ||\n href.startsWith('#');\n\nconst isRouterLink = (href?: string) => href && !isExternalLink(href);\n\nconst cssVars = computed(() => ({\n '--base': props.baseColor,\n '--pill-bg': props.pillColor,\n '--hover-text': props.hoveredPillTextColor,\n '--pill-text': resolvedPillTextColor,\n '--nav-h': '42px',\n '--logo': '36px',\n '--pill-pad-x': '18px',\n '--pill-gap': '3px'\n}));\n\nconst setCircleRef = (el: HTMLSpanElement | null, index: number) => {\n if (circleRefs.value.length > index) {\n circleRefs.value[index] = el;\n }\n};\n</script>\n\n<template>\n <div class=\"top-[1em] left-0 md:left-auto z-[1000] absolute w-full md:w-auto\">\n <nav\n :class=\"['w-full md:w-max flex items-center justify-between md:justify-start box-border px-4 md:px-0', className]\"\n aria-label=\"Primary\"\n :style=\"cssVars\"\n >\n <component\n :is=\"isRouterLink(items?.[0]?.href) ? 'RouterLink' : 'a'\"\n :to=\"isRouterLink(items?.[0]?.href) ? items[0].href : undefined\"\n :href=\"!isRouterLink(items?.[0]?.href) ? items?.[0]?.href || '#' : undefined\"\n aria-label=\"Home\"\n role=\"menuitem\"\n ref=\"logoRef\"\n class=\"inline-flex justify-center items-center p-2 rounded-full overflow-hidden\"\n :style=\"{\n width: 'var(--nav-h)',\n height: 'var(--nav-h)',\n background: 'var(--base, #000)'\n }\"\n @mouseenter=\"handleLogoEnter\"\n >\n <img :src=\"logo\" :alt=\"logoAlt\" ref=\"logoImgRef\" class=\"block w-full h-full object-cover\" />\n </component>\n\n <div\n ref=\"navItemsRef\"\n class=\"hidden relative md:flex items-center ml-2 rounded-full\"\n :style=\"{\n height: 'var(--nav-h)',\n background: 'var(--base, #000)'\n }\"\n >\n <ul role=\"menubar\" class=\"flex items-stretch m-0 p-[3px] h-full list-none\" :style=\"{ gap: 'var(--pill-gap)' }\">\n <li v-for=\"(item, i) in items\" :key=\"item.href || `item-${i}`\" class=\"flex h-full\" role=\"none\">\n <component\n :is=\"isRouterLink(item.href) ? 'RouterLink' : 'a'\"\n :to=\"isRouterLink(item.href) ? item.href : undefined\"\n :href=\"!isRouterLink(item.href) ? item.href : undefined\"\n class=\"inline-flex box-border relative justify-center items-center px-0 rounded-full h-full overflow-hidden font-semibold text-[16px] no-underline uppercase leading-[0] tracking-[0.2px] whitespace-nowrap cursor-pointer\"\n :style=\"{\n background: 'var(--pill-bg, #fff)',\n color: 'var(--pill-text, var(--base, #000))',\n paddingLeft: 'var(--pill-pad-x)',\n paddingRight: 'var(--pill-pad-x)'\n }\"\n :aria-label=\"item.ariaLabel || item.label\"\n @mouseenter=\"handleEnter(i)\"\n @mouseleave=\"handleLeave(i)\"\n >\n <span\n class=\"block bottom-0 left-1/2 z-[1] absolute rounded-full pointer-events-none hover-circle\"\n :style=\"{\n background: 'var(--base, #000)',\n willChange: 'transform'\n }\"\n aria-hidden=\"true\"\n :ref=\"el => setCircleRef(el as HTMLSpanElement, i)\"\n />\n <span class=\"inline-block z-[2] relative leading-[1] label-stack\">\n <span class=\"inline-block z-[2] relative leading-[1] pill-label\" :style=\"{ willChange: 'transform' }\">\n {{ item.label }}\n </span>\n <span\n class=\"inline-block top-0 left-0 z-[3] absolute pill-label-hover\"\n :style=\"{\n color: 'var(--hover-text, #fff)',\n willChange: 'transform, opacity'\n }\"\n aria-hidden=\"true\"\n >\n {{ item.label }}\n </span>\n </span>\n <span\n v-if=\"activeHref === item.href\"\n class=\"-bottom-[6px] left-1/2 z-[4] absolute rounded-full w-3 h-3 -translate-x-1/2\"\n :style=\"{ background: 'var(--base, #000)' }\"\n aria-hidden=\"true\"\n />\n </component>\n </li>\n </ul>\n </div>\n\n <button\n ref=\"hamburgerRef\"\n @click=\"toggleMobileMenu\"\n aria-label=\"Toggle menu\"\n :aria-expanded=\"isMobileMenuOpen\"\n class=\"md:hidden relative flex flex-col justify-center items-center gap-1 p-0 border-0 rounded-full cursor-pointer\"\n :style=\"{\n width: 'var(--nav-h)',\n height: 'var(--nav-h)',\n background: 'var(--base, #000)'\n }\"\n >\n <span\n class=\"rounded w-4 h-0.5 origin-center transition-all duration-[10ms] ease-[cubic-bezier(0.25,0.1,0.25,1)] hamburger-line\"\n :style=\"{ background: 'var(--pill-bg, #fff)' }\"\n />\n <span\n class=\"rounded w-4 h-0.5 origin-center transition-all duration-[10ms] ease-[cubic-bezier(0.25,0.1,0.25,1)] hamburger-line\"\n :style=\"{ background: 'var(--pill-bg, #fff)' }\"\n />\n </button>\n </nav>\n\n <div\n ref=\"mobileMenuRef\"\n class=\"md:hidden top-[3em] right-4 left-4 z-[998] absolute shadow-[0_8px_32px_rgba(0,0,0,0.12)] rounded-[27px] origin-top\"\n :style=\"{\n ...cssVars,\n background: 'var(--base, #f0f0f0)'\n }\"\n >\n <ul class=\"flex flex-col gap-[3px] m-0 p-[3px] list-none\">\n <li v-for=\"item in items\" :key=\"item.href || `mobile-${item.label}`\">\n <component\n :is=\"isRouterLink(item.href) ? 'RouterLink' : 'a'\"\n :to=\"isRouterLink(item.href) ? item.href : undefined\"\n :href=\"!isRouterLink(item.href) ? item.href : undefined\"\n class=\"block px-4 py-3 rounded-[50px] font-medium text-[16px] transition-all duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]\"\n :style=\"{ background: 'var(--pill-bg, #fff)', color: 'var(--pill-text, #fff)' }\"\n @mouseenter=\"\n (e: MouseEvent) => {\n (e.currentTarget as HTMLAnchorElement).style.background = 'var(--base)';\n (e.currentTarget as HTMLAnchorElement).style.color = 'var(--hover-text, #fff)';\n }\n \"\n @mouseleave=\"\n (e: MouseEvent) => {\n (e.currentTarget as HTMLAnchorElement).style.background = 'var(--pill-bg, #fff)';\n (e.currentTarget as HTMLAnchorElement).style.color = 'var(--pill-text, #fff)';\n }\n \"\n >\n {{ item.label }}\n </component>\n </li>\n </ul>\n </div>\n </div>\n</template>\n","path":"PillNav/PillNav.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["Components"]} |