Merge pull request #70 from Utkarsh-Singhal-26/feat/pillnav

Added <PillNav /> Component
This commit is contained in:
David
2025-08-15 11:00:50 +03:00
committed by GitHub
6 changed files with 667 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
// Highlighted sidebar items
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel', 'Scroll Stack', 'Faulty Terminal'];
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel', 'Scroll Stack', 'Faulty Terminal', 'Pill Nav'];
export const UPDATED = [];
// Used for main sidebar navigation
@@ -66,6 +66,7 @@ export const CATEGORIES = [
'Magic Bento',
'Scroll Stack',
'Profile Card',
'Pill Nav',
'Dock',
'Gooey Nav',
'Pixel Card',

View File

@@ -54,6 +54,7 @@ const components = {
'magic-bento': () => import('../demo/Components/MagicBentoDemo.vue'),
'profile-card': () => import('../demo/Components/ProfileCardDemo.vue'),
'dock': () => import('../demo/Components/DockDemo.vue'),
'pill-nav': () => import('../demo/Components/PillNavDemo.vue'),
'gooey-nav': () => import('../demo/Components/GooeyNavDemo.vue'),
'pixel-card': () => import('../demo/Components/PixelCardDemo.vue'),
'carousel': () => import('../demo/Components/CarouselDemo.vue'),

View File

@@ -0,0 +1,30 @@
import code from '@content/Components/PillNav/PillNav.vue?raw';
import { createCodeObject } from '../../../types/code';
export const pillNav = createCodeObject(code, 'Components/PillNav', {
installation: `npm install gsap`,
usage: `<template>
<PillNav
:logo="logo"
logoAlt="Company Logo"
:items="[
{ label: 'Home', href: '/' },
{ label: 'About', href: '/about' },
{ label: 'Services', href: '/services' },
{ label: 'Contact', href: '/contact' }
]"
activeHref="/"
class="custom-nav"
ease="power2.easeOut"
baseColor="#000000"
pillColor="#ffffff"
hoveredPillTextColor="#ffffff"
pillTextColor="#000000"
/>
</template>
<script setup lang="ts">
import PillNav from "./PillNav.vue";
import logo from '/path/to/logo.svg';
</script>`
});

View File

@@ -0,0 +1,416 @@
<script setup lang="ts">
import { gsap } from 'gsap';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
type PillNavItem = {
label: string;
href?: string;
ariaLabel?: string;
};
interface PillNavProps {
logo: string;
logoAlt?: string;
items: PillNavItem[];
activeHref?: string;
className?: string;
ease?: string;
baseColor?: string;
pillColor?: string;
hoveredPillTextColor?: string;
pillTextColor?: string;
onMobileMenuClick?: () => void;
initialLoadAnimation?: boolean;
}
const props = withDefaults(defineProps<PillNavProps>(), {
logoAlt: 'Logo',
className: '',
ease: 'power3.easeOut',
baseColor: '#fff',
pillColor: '#060010',
hoveredPillTextColor: '#060010',
initialLoadAnimation: true
});
const resolvedPillTextColor = props.pillTextColor ?? props.baseColor;
const isMobileMenuOpen = ref(false);
const circleRefs = ref<Array<HTMLSpanElement | null>>([]);
const tlRefs = ref<Array<gsap.core.Timeline | null>>([]);
const activeTweenRefs = ref<Array<gsap.core.Tween | null>>([]);
const logoImgRef = useTemplateRef('logoImgRef');
const logoTweenRef = ref<gsap.core.Tween | null>(null);
const hamburgerRef = useTemplateRef('hamburgerRef');
const mobileMenuRef = useTemplateRef('mobileMenuRef');
const navItemsRef = useTemplateRef('navItemsRef');
const logoRef = useTemplateRef('logoRef');
watch(
() => props.items,
items => {
circleRefs.value = new Array(items.length).fill(null);
tlRefs.value = new Array(items.length).fill(null);
activeTweenRefs.value = new Array(items.length).fill(null);
},
{ immediate: true }
);
const layout = () => {
circleRefs.value.forEach(circle => {
if (!circle?.parentElement) return;
const pill = circle.parentElement as HTMLElement;
const rect = pill.getBoundingClientRect();
const { width: w, height: h } = rect;
const R = ((w * w) / 4 + h * h) / (2 * h);
const D = Math.ceil(2 * R) + 2;
const delta = Math.ceil(R - Math.sqrt(Math.max(0, R * R - (w * w) / 4))) + 1;
const originY = D - delta;
circle.style.width = `${D}px`;
circle.style.height = `${D}px`;
circle.style.bottom = `-${delta}px`;
gsap.set(circle, {
xPercent: -50,
scale: 0,
transformOrigin: `50% ${originY}px`
});
const label = pill.querySelector<HTMLElement>('.pill-label');
const white = pill.querySelector<HTMLElement>('.pill-label-hover');
if (label) gsap.set(label, { y: 0 });
if (white) gsap.set(white, { y: h + 12, opacity: 0 });
const index = circleRefs.value.indexOf(circle);
if (index === -1) return;
tlRefs.value[index]?.kill();
const tl = gsap.timeline({ paused: true });
tl.to(circle, { scale: 1.2, xPercent: -50, duration: 2, ease: props.ease, overwrite: 'auto' }, 0);
if (label) {
tl.to(label, { y: -(h + 8), duration: 2, ease: props.ease, overwrite: 'auto' }, 0);
}
if (white) {
gsap.set(white, { y: Math.ceil(h + 100), opacity: 0 });
tl.to(white, { y: 0, opacity: 1, duration: 2, ease: props.ease, overwrite: 'auto' }, 0);
}
tlRefs.value[index] = tl;
});
};
const onResize = () => layout();
onMounted(() => {
layout();
window.addEventListener('resize', onResize);
if (document.fonts) {
document.fonts.ready.then(layout).catch(() => {});
}
const menu = mobileMenuRef.value;
if (menu) {
gsap.set(menu, { visibility: 'hidden', opacity: 0, scaleY: 1, y: 0 });
}
if (props.initialLoadAnimation) {
const logo = logoRef.value;
const navItems = navItemsRef.value;
if (logo) {
gsap.set(logo, { scale: 0 });
gsap.to(logo, {
scale: 1,
duration: 0.6,
ease: props.ease
});
}
if (navItems) {
gsap.set(navItems, { width: 0, overflow: 'hidden' });
gsap.to(navItems, {
width: 'auto',
duration: 0.6,
ease: props.ease
});
}
}
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
});
watch(
() => [props.items, props.ease, props.initialLoadAnimation],
() => {
layout();
},
{ deep: true }
);
const handleEnter = (i: number) => {
const tl = tlRefs.value[i];
if (!tl) return;
activeTweenRefs.value[i]?.kill();
activeTweenRefs.value[i] = tl.tweenTo(tl.duration(), {
duration: 0.3,
ease: props.ease,
overwrite: 'auto'
});
};
const handleLeave = (i: number) => {
const tl = tlRefs.value[i];
if (!tl) return;
activeTweenRefs.value[i]?.kill();
activeTweenRefs.value[i] = tl.tweenTo(0, {
duration: 0.2,
ease: props.ease,
overwrite: 'auto'
});
};
const handleLogoEnter = () => {
const img = logoImgRef.value;
if (!img) return;
logoTweenRef.value?.kill();
gsap.set(img, { rotate: 0 });
logoTweenRef.value = gsap.to(img, {
rotate: 360,
duration: 0.2,
ease: props.ease,
overwrite: 'auto'
});
};
const toggleMobileMenu = () => {
const newState = !isMobileMenuOpen.value;
isMobileMenuOpen.value = newState;
const hamburger = hamburgerRef.value;
const menu = mobileMenuRef.value;
if (hamburger) {
const lines = hamburger.querySelectorAll('.hamburger-line');
if (newState) {
gsap.to(lines[0], { rotation: 45, y: 3, duration: 0.3, ease: props.ease });
gsap.to(lines[1], { rotation: -45, y: -3, duration: 0.3, ease: props.ease });
} else {
gsap.to(lines[0], { rotation: 0, y: 0, duration: 0.3, ease: props.ease });
gsap.to(lines[1], { rotation: 0, y: 0, duration: 0.3, ease: props.ease });
}
}
if (menu) {
if (newState) {
gsap.set(menu, { visibility: 'visible' });
gsap.fromTo(
menu,
{ opacity: 0, y: 10, scaleY: 1 },
{
opacity: 1,
y: 0,
scaleY: 1,
duration: 0.3,
ease: props.ease,
transformOrigin: 'top center'
}
);
} else {
gsap.to(menu, {
opacity: 0,
y: 10,
scaleY: 1,
duration: 0.2,
ease: props.ease,
transformOrigin: 'top center',
onComplete: () => {
gsap.set(menu, { visibility: 'hidden' });
}
});
}
}
props.onMobileMenuClick?.();
};
const isExternalLink = (href: string) =>
href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('//') ||
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('#');
const isRouterLink = (href?: string) => href && !isExternalLink(href);
const cssVars = computed(() => ({
'--base': props.baseColor,
'--pill-bg': props.pillColor,
'--hover-text': props.hoveredPillTextColor,
'--pill-text': resolvedPillTextColor,
'--nav-h': '42px',
'--logo': '36px',
'--pill-pad-x': '18px',
'--pill-gap': '3px'
}));
const setCircleRef = (el: HTMLSpanElement | null, index: number) => {
if (circleRefs.value.length > index) {
circleRefs.value[index] = el;
}
};
</script>
<template>
<div class="top-[1em] left-0 md:left-auto z-[1000] absolute w-full md:w-auto">
<nav
:class="['w-full md:w-max flex items-center justify-between md:justify-start box-border px-4 md:px-0', className]"
aria-label="Primary"
:style="cssVars"
>
<component
:is="isRouterLink(items?.[0]?.href) ? 'RouterLink' : 'a'"
:to="isRouterLink(items?.[0]?.href) ? items[0].href : undefined"
:href="!isRouterLink(items?.[0]?.href) ? items?.[0]?.href || '#' : undefined"
aria-label="Home"
role="menuitem"
ref="logoRef"
class="inline-flex justify-center items-center p-2 rounded-full overflow-hidden"
:style="{
width: 'var(--nav-h)',
height: 'var(--nav-h)',
background: 'var(--base, #000)'
}"
@mouseenter="handleLogoEnter"
>
<img :src="logo" :alt="logoAlt" ref="logoImgRef" class="block w-full h-full object-cover" />
</component>
<div
ref="navItemsRef"
class="hidden relative md:flex items-center ml-2 rounded-full"
:style="{
height: 'var(--nav-h)',
background: 'var(--base, #000)'
}"
>
<ul role="menubar" class="flex items-stretch m-0 p-[3px] h-full list-none" :style="{ gap: 'var(--pill-gap)' }">
<li v-for="(item, i) in items" :key="item.href || `item-${i}`" class="flex h-full" role="none">
<component
:is="isRouterLink(item.href) ? 'RouterLink' : 'a'"
:to="isRouterLink(item.href) ? item.href : undefined"
:href="!isRouterLink(item.href) ? item.href : undefined"
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"
:style="{
background: 'var(--pill-bg, #fff)',
color: 'var(--pill-text, var(--base, #000))',
paddingLeft: 'var(--pill-pad-x)',
paddingRight: 'var(--pill-pad-x)'
}"
:aria-label="item.ariaLabel || item.label"
@mouseenter="handleEnter(i)"
@mouseleave="handleLeave(i)"
>
<span
class="block bottom-0 left-1/2 z-[1] absolute rounded-full pointer-events-none hover-circle"
:style="{
background: 'var(--base, #000)',
willChange: 'transform'
}"
aria-hidden="true"
:ref="el => setCircleRef(el as HTMLSpanElement, i)"
/>
<span class="inline-block z-[2] relative leading-[1] label-stack">
<span class="inline-block z-[2] relative leading-[1] pill-label" :style="{ willChange: 'transform' }">
{{ item.label }}
</span>
<span
class="inline-block top-0 left-0 z-[3] absolute pill-label-hover"
:style="{
color: 'var(--hover-text, #fff)',
willChange: 'transform, opacity'
}"
aria-hidden="true"
>
{{ item.label }}
</span>
</span>
<span
v-if="activeHref === item.href"
class="-bottom-[6px] left-1/2 z-[4] absolute rounded-full w-3 h-3 -translate-x-1/2"
:style="{ background: 'var(--base, #000)' }"
aria-hidden="true"
/>
</component>
</li>
</ul>
</div>
<button
ref="hamburgerRef"
@click="toggleMobileMenu"
aria-label="Toggle menu"
:aria-expanded="isMobileMenuOpen"
class="md:hidden relative flex flex-col justify-center items-center gap-1 p-0 border-0 rounded-full cursor-pointer"
:style="{
width: 'var(--nav-h)',
height: 'var(--nav-h)',
background: 'var(--base, #000)'
}"
>
<span
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"
:style="{ background: 'var(--pill-bg, #fff)' }"
/>
<span
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"
:style="{ background: 'var(--pill-bg, #fff)' }"
/>
</button>
</nav>
<div
ref="mobileMenuRef"
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"
:style="{
...cssVars,
background: 'var(--base, #f0f0f0)'
}"
>
<ul class="flex flex-col gap-[3px] m-0 p-[3px] list-none">
<li v-for="item in items" :key="item.href || `mobile-${item.label}`">
<component
:is="isRouterLink(item.href) ? 'RouterLink' : 'a'"
:to="isRouterLink(item.href) ? item.href : undefined"
:href="!isRouterLink(item.href) ? item.href : undefined"
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)]"
:style="{ background: 'var(--pill-bg, #fff)', color: 'var(--pill-text, #fff)' }"
@mouseenter="
(e: MouseEvent) => {
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--base)';
(e.currentTarget as HTMLAnchorElement).style.color = 'var(--hover-text, #fff)';
}
"
@mouseleave="
(e: MouseEvent) => {
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--pill-bg, #fff)';
(e.currentTarget as HTMLAnchorElement).style.color = 'var(--pill-text, #fff)';
}
"
>
{{ item.label }}
</component>
</li>
</ul>
</div>
</div>
</template>

View File

@@ -106,6 +106,31 @@
color: #e5e7eb;
}
.demo-container-dots {
position: relative;
}
.demo-container-dots::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: radial-gradient(circle, #165f2b 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
z-index: 0;
mask:
linear-gradient(to right, transparent 0%, black 35%, black 55%, transparent 100%),
linear-gradient(to bottom, transparent 0%, black 35%, black 55%, transparent 100%);
mask-composite: intersect;
-webkit-mask:
linear-gradient(to right, transparent 0%, black 35%, black 55%, transparent 100%),
linear-gradient(to bottom, transparent 0%, black 35%, black 55%, transparent 100%);
-webkit-mask-composite: source-in;
}
div:has(> .props-table) {
border: 1px solid #333;
border-radius: 20px;

View File

@@ -0,0 +1,193 @@
<template>
<TabbedLayout>
<template #preview>
<div
class="relative overflow-hidden demo-container demo-container-dots"
:style="{
backgroundColor: currentTheme.backgroundColor,
minHeight: '300px',
height: 'auto'
}"
>
<PillNav
:key="key"
:logo="currentTheme.logo"
:baseColor="currentTheme.baseColor"
:pillColor="currentTheme.pillColor"
:hoveredPillTextColor="currentTheme.hoveredPillTextColor"
:pillTextColor="currentTheme.pillTextColor"
:initialLoadAnimation="initialLoadAnimation"
:items="[{ label: 'Home' }, { label: 'About' }, { label: 'Contact' }]"
activeHref="/"
/>
</div>
<Customize>
<PreviewSelect title="Example" :options="themeOptions" v-model="theme" />
<PreviewSwitch title="Initial Load Animation" v-model="initialLoadAnimation" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['gsap']" />
</template>
<template #code>
<CodeExample :code-object="pillNav" />
</template>
<template #cli>
<CliInstallation :command="pillNav.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { useForceRerender } from '@/composables/useForceRerender';
import { computed, ref, watch } from 'vue';
import logoDark from '../../assets/logos/vue-bits-logo-small-dark.svg';
import logoLight from '../../assets/logos/vue-bits-logo-small.svg';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSelect from '../../components/common/PreviewSelect.vue';
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
import PropTable from '../../components/common/PropTable.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import { pillNav } from '../../constants/code/Components/PillNavCode';
import PillNav from '../../content/Components/PillNav/PillNav.vue';
const { rerenderKey: key, forceRerender } = useForceRerender();
type ThemeKey = 'light' | 'dark' | 'color';
interface ThemeConfig {
logo: string;
baseColor: string;
pillColor: string;
hoveredPillTextColor: string;
pillTextColor: string;
backgroundColor: string;
}
const theme = ref<ThemeKey>('light');
const initialLoadAnimation = ref(false);
const themeConfigs: Record<ThemeKey, ThemeConfig> = {
light: {
logo: logoLight,
baseColor: '#000',
pillColor: '#f0f0f0',
hoveredPillTextColor: '#fff',
pillTextColor: '#000',
backgroundColor: '#f0f0f0'
},
dark: {
logo: logoDark,
baseColor: '#fff',
pillColor: '#060010',
hoveredPillTextColor: '#000',
pillTextColor: '#fff',
backgroundColor: '#060010'
},
color: {
logo: logoDark,
baseColor: '#27FF64',
pillColor: '#060010',
hoveredPillTextColor: '#060010',
pillTextColor: '#fff',
backgroundColor: '#060010'
}
};
const themeOptions = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'color', label: 'Color' }
];
const currentTheme = computed(() => themeConfigs[theme.value]);
watch(
[currentTheme, initialLoadAnimation],
() => {
forceRerender();
},
{ immediate: true }
);
const propData = [
{
name: 'logo',
type: 'string',
default: '-',
description: 'URL for the logo image'
},
{
name: 'logoAlt',
type: 'string',
default: 'Logo',
description: 'Alt text for the logo image'
},
{
name: 'items',
type: 'PillNavItem[]',
default: '-',
description: 'Array of navigation items with label, href, and optional ariaLabel'
},
{
name: 'activeHref',
type: 'string',
default: 'undefined',
description: 'The href of the currently active navigation item'
},
{
name: 'className',
type: 'string',
default: "''",
description: 'Additional CSS classes for the navigation container'
},
{
name: 'ease',
type: 'string',
default: 'power3.easeOut',
description: 'GSAP easing function for animations'
},
{
name: 'baseColor',
type: 'string',
default: '#fff',
description: 'Base background color for the navigation'
},
{
name: 'pillColor',
type: 'string',
default: '#060010',
description: 'Background color for navigation pills'
},
{
name: 'hoveredPillTextColor',
type: 'string',
default: '#060010',
description: 'Text color when hovering over pills'
},
{
name: 'pillTextColor',
type: 'string',
default: 'baseColor',
description: 'Text color for navigation pills'
},
{
name: 'onMobileMenuClick',
type: '() => void',
default: 'undefined',
description: 'Callback function triggered when mobile menu button is clicked'
},
{
name: 'initialLoadAnimation',
type: 'boolean',
default: 'false',
description: 'Enable initial load animation for logo scale and nav items reveal'
}
];
</script>