mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
10 KiB
JSON
1 line
10 KiB
JSON
{"name":"GooeyNav","title":"GooeyNav","description":"Navigation indicator morphs with gooey blob transitions between items.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <div>\n <div class=\"relative\" ref=\"containerRef\">\n <nav class=\"flex relative\" :style=\"{ transform: 'translate3d(0,0,0.01px)' }\">\n <ul\n ref=\"navRef\"\n class=\"flex gap-8 list-none p-0 px-4 m-0 relative z-[3]\"\n :style=\"{\n color: 'white',\n textShadow: '0 1px 1px hsl(205deg 30% 10% / 0.2)'\n }\"\n >\n <li\n v-for=\"(item, index) in items\"\n :key=\"index\"\n :class=\"[\n 'rounded-full relative cursor-pointer transition-[background-color_color_box-shadow] duration-300 ease shadow-[0_0_0.5px_1.5px_transparent] text-white',\n { active: activeIndex === index }\n ]\"\n >\n <a\n :href=\"item.href || undefined\"\n @click=\"e => handleClick(e, index)\"\n @keydown=\"e => handleKeyDown(e, index)\"\n class=\"outline-none py-[0.6em] px-[1em] inline-block\"\n >\n {{ item.label }}\n </a>\n </li>\n </ul>\n </nav>\n\n <span class=\"effect filter\" ref=\"filterRef\" />\n\n <span class=\"effect text\" ref=\"textRef\" />\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';\n\ninterface GooeyNavItem {\n label: string;\n href: string | null;\n}\n\ninterface GooeyNavProps {\n items: GooeyNavItem[];\n animationTime?: number;\n particleCount?: number;\n particleDistances?: [number, number];\n particleR?: number;\n timeVariance?: number;\n colors?: number[];\n initialActiveIndex?: number;\n}\n\nconst props = withDefaults(defineProps<GooeyNavProps>(), {\n animationTime: 600,\n particleCount: 15,\n particleDistances: () => [90, 10],\n particleR: 100,\n timeVariance: 300,\n colors: () => [1, 2, 3, 1, 2, 3, 1, 4],\n initialActiveIndex: 0\n});\n\nconst containerRef = useTemplateRef<HTMLDivElement>('containerRef');\nconst navRef = useTemplateRef<HTMLUListElement>('navRef');\nconst filterRef = useTemplateRef<HTMLSpanElement>('filterRef');\nconst textRef = useTemplateRef<HTMLSpanElement>('textRef');\nconst activeIndex = ref<number>(props.initialActiveIndex);\n\nlet resizeObserver: ResizeObserver | null = null;\n\nconst noise = (n = 1) => n / 2 - Math.random() * n;\n\nconst getXY = (distance: number, pointIndex: number, totalPoints: number): [number, number] => {\n const angle = ((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180);\n return [distance * Math.cos(angle), distance * Math.sin(angle)];\n};\n\nconst createParticle = (i: number, t: number, d: [number, number], r: number) => {\n const rotate = noise(r / 10);\n return {\n start: getXY(d[0], props.particleCount - i, props.particleCount),\n end: getXY(d[1] + noise(7), props.particleCount - i, props.particleCount),\n time: t,\n scale: 1 + noise(0.2),\n color: props.colors[Math.floor(Math.random() * props.colors.length)],\n rotate: rotate > 0 ? (rotate + r / 20) * 10 : (rotate - r / 20) * 10\n };\n};\n\nconst makeParticles = (element: HTMLElement) => {\n const d: [number, number] = props.particleDistances;\n const r = props.particleR;\n const bubbleTime = props.animationTime * 2 + props.timeVariance;\n element.style.setProperty('--time', `${bubbleTime}ms`);\n for (let i = 0; i < props.particleCount; i++) {\n const t = props.animationTime * 2 + noise(props.timeVariance * 2);\n const p = createParticle(i, t, d, r);\n element.classList.remove('active');\n setTimeout(() => {\n const particle = document.createElement('span');\n const point = document.createElement('span');\n particle.classList.add('particle');\n particle.style.setProperty('--start-x', `${p.start[0]}px`);\n particle.style.setProperty('--start-y', `${p.start[1]}px`);\n particle.style.setProperty('--end-x', `${p.end[0]}px`);\n particle.style.setProperty('--end-y', `${p.end[1]}px`);\n particle.style.setProperty('--time', `${p.time}ms`);\n particle.style.setProperty('--scale', `${p.scale}`);\n particle.style.setProperty('--color', `var(--color-${p.color}, white)`);\n particle.style.setProperty('--rotate', `${p.rotate}deg`);\n point.classList.add('point');\n particle.appendChild(point);\n element.appendChild(particle);\n requestAnimationFrame(() => {\n element.classList.add('active');\n });\n setTimeout(() => {\n try {\n element.removeChild(particle);\n } catch {}\n }, t);\n }, 30);\n }\n};\n\nconst updateEffectPosition = (element: HTMLElement) => {\n if (!containerRef.value || !filterRef.value || !textRef.value) return;\n const containerRect = containerRef.value.getBoundingClientRect();\n const pos = element.getBoundingClientRect();\n const styles = {\n left: `${pos.x - containerRect.x}px`,\n top: `${pos.y - containerRect.y}px`,\n width: `${pos.width}px`,\n height: `${pos.height}px`\n };\n Object.assign(filterRef.value.style, styles);\n Object.assign(textRef.value.style, styles);\n textRef.value.innerText = element.innerText;\n};\n\nconst handleClick = (e: Event, index: number) => {\n const liEl = (e.currentTarget as HTMLElement).parentElement as HTMLElement;\n if (activeIndex.value === index) return;\n activeIndex.value = index;\n updateEffectPosition(liEl);\n if (filterRef.value) {\n const particles = filterRef.value.querySelectorAll('.particle');\n particles.forEach(p => filterRef.value!.removeChild(p));\n }\n if (textRef.value) {\n textRef.value.classList.remove('active');\n void textRef.value.offsetWidth;\n textRef.value.classList.add('active');\n }\n if (filterRef.value) {\n makeParticles(filterRef.value);\n }\n};\n\nconst handleKeyDown = (e: KeyboardEvent, index: number) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n const liEl = (e.currentTarget as HTMLElement).parentElement;\n if (liEl) {\n handleClick(\n {\n currentTarget: liEl\n } as unknown as Event,\n index\n );\n }\n }\n};\n\nwatch(activeIndex, () => {\n if (!navRef.value || !containerRef.value) return;\n const activeLi = navRef.value.querySelectorAll('li')[activeIndex.value] as HTMLElement;\n if (activeLi) {\n updateEffectPosition(activeLi);\n textRef.value?.classList.add('active');\n }\n});\n\nonMounted(() => {\n if (!navRef.value || !containerRef.value) return;\n const activeLi = navRef.value.querySelectorAll('li')[activeIndex.value] as HTMLElement;\n if (activeLi) {\n updateEffectPosition(activeLi);\n textRef.value?.classList.add('active');\n }\n resizeObserver = new ResizeObserver(() => {\n const currentActiveLi = navRef.value?.querySelectorAll('li')[activeIndex.value] as HTMLElement;\n if (currentActiveLi) {\n updateEffectPosition(currentActiveLi);\n }\n });\n resizeObserver.observe(containerRef.value);\n});\n\nonUnmounted(() => {\n if (resizeObserver) {\n resizeObserver.disconnect();\n }\n});\n</script>\n\n<style>\n:root {\n --linear-ease: linear(\n 0,\n 0.068,\n 0.19 2.7%,\n 0.804 8.1%,\n 1.037,\n 1.199 13.2%,\n 1.245,\n 1.27 15.8%,\n 1.274,\n 1.272 17.4%,\n 1.249 19.1%,\n 0.996 28%,\n 0.949,\n 0.928 33.3%,\n 0.926,\n 0.933 36.8%,\n 1.001 45.6%,\n 1.013,\n 1.019 50.8%,\n 1.018 54.4%,\n 1 63.1%,\n 0.995 68%,\n 1.001 85%,\n 1\n );\n}\n\n.effect {\n position: absolute;\n opacity: 1;\n pointer-events: none;\n display: grid;\n place-items: center;\n z-index: 1;\n}\n\n.effect.text {\n color: white;\n transition: color 0.3s ease;\n}\n\n.effect.text.active {\n color: black;\n}\n\n.effect.filter {\n filter: blur(7px) contrast(100) blur(0);\n mix-blend-mode: lighten;\n}\n\n.effect.filter::before {\n content: '';\n position: absolute;\n inset: -75px;\n z-index: -2;\n background: black;\n}\n\n.effect.filter::after {\n content: '';\n position: absolute;\n inset: 0;\n background: white;\n transform: scale(0);\n opacity: 0;\n z-index: -1;\n border-radius: 9999px;\n}\n\n.effect.active::after {\n animation: pill 0.3s ease both;\n}\n\n@keyframes pill {\n to {\n transform: scale(1);\n opacity: 1;\n }\n}\n\n.particle,\n.point {\n display: block;\n opacity: 0;\n width: 20px;\n height: 20px;\n border-radius: 9999px;\n transform-origin: center;\n}\n\n.particle {\n --time: 5s;\n position: absolute;\n top: calc(50% - 8px);\n left: calc(50% - 8px);\n animation: particle calc(var(--time)) ease 1 -350ms;\n}\n\n.point {\n background: var(--color);\n opacity: 1;\n animation: point calc(var(--time)) ease 1 -350ms;\n}\n\n@keyframes particle {\n 0% {\n transform: rotate(0deg) translate(calc(var(--start-x)), calc(var(--start-y)));\n opacity: 1;\n animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);\n }\n 70% {\n transform: rotate(calc(var(--rotate) * 0.5)) translate(calc(var(--end-x) * 1.2), calc(var(--end-y) * 1.2));\n opacity: 1;\n animation-timing-function: ease;\n }\n 85% {\n transform: rotate(calc(var(--rotate) * 0.66)) translate(calc(var(--end-x)), calc(var(--end-y)));\n opacity: 1;\n }\n 100% {\n transform: rotate(calc(var(--rotate) * 1.2)) translate(calc(var(--end-x) * 0.5), calc(var(--end-y) * 0.5));\n opacity: 1;\n }\n}\n\n@keyframes point {\n 0% {\n transform: scale(0);\n opacity: 0;\n animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);\n }\n 25% {\n transform: scale(calc(var(--scale) * 0.25));\n }\n 38% {\n opacity: 1;\n }\n 65% {\n transform: scale(var(--scale));\n opacity: 1;\n animation-timing-function: ease;\n }\n 85% {\n transform: scale(var(--scale));\n opacity: 1;\n }\n 100% {\n transform: scale(0);\n opacity: 0;\n }\n}\n\nli.active {\n color: black;\n text-shadow: none;\n}\n\nli.active::after {\n opacity: 1;\n transform: scale(1);\n}\n\nli::after {\n content: '';\n position: absolute;\n inset: 0;\n border-radius: 8px;\n background: white;\n opacity: 0;\n transform: scale(0);\n transition: all 0.3s ease;\n z-index: -1;\n}\n</style>\n","path":"GooeyNav/GooeyNav.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["Components"]} |