| import { useEffect, useRef, useState } from 'react'; |
| import { Link, useLocation } from 'react-router-dom'; |
| import { gsap } from 'gsap'; |
| import './PillNav.css'; |
|
|
| export interface PillNavItem { |
| label: string; |
| href?: string; |
| ariaLabel?: string; |
| onClick?: () => void; |
| } |
|
|
| interface PillNavProps { |
| logo?: string; |
| logoText?: string; |
| logoAlt?: string; |
| items: PillNavItem[]; |
| activeHref?: string; |
| className?: string; |
| ease?: string; |
| baseColor?: string; |
| pillColor?: string; |
| hoveredPillTextColor?: string; |
| pillTextColor?: string; |
| onMobileMenuClick?: () => void; |
| initialLoadAnimation?: boolean; |
| } |
|
|
| const PillNav = ({ |
| logo, |
| logoText, |
| logoAlt = 'Logo', |
| items, |
| activeHref, |
| className = '', |
| ease = 'power3.easeOut', |
| baseColor = '#0d0720', |
| pillColor = '#1e0f3a', |
| hoveredPillTextColor = '#e9d5ff', |
| pillTextColor, |
| onMobileMenuClick, |
| initialLoadAnimation = true, |
| }: PillNavProps) => { |
| const resolvedPillTextColor = pillTextColor ?? '#c084fc'; |
| const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); |
|
|
| const circleRefs = useRef<(HTMLSpanElement | null)[]>([]); |
| const tlRefs = useRef<gsap.core.Timeline[]>([]); |
| const activeTweenRefs = useRef<gsap.core.Tween[]>([]); |
| const logoImgRef = useRef<HTMLImageElement>(null); |
| const logoTweenRef = useRef<gsap.core.Tween | null>(null); |
| const hamburgerRef = useRef<HTMLButtonElement>(null); |
| const mobileMenuRef = useRef<HTMLDivElement>(null); |
| const navItemsRef = useRef<HTMLDivElement>(null); |
| const logoRef = useRef<HTMLAnchorElement | null>(null); |
|
|
| useEffect(() => { |
| const layout = () => { |
| circleRefs.current.forEach((circle, index) => { |
| if (!circle?.parentElement) return; |
| const pill = circle.parentElement; |
| 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('.pill-label'); |
| const white = pill.querySelector('.pill-label-hover'); |
| if (label) gsap.set(label, { y: 0 }); |
| if (white) gsap.set(white, { y: h + 12, opacity: 0 }); |
|
|
| tlRefs.current[index]?.kill(); |
| const tl = gsap.timeline({ paused: true }); |
| tl.to(circle, { scale: 1.2, xPercent: -50, duration: 2, ease, overwrite: 'auto' }, 0); |
| if (label) tl.to(label, { y: -(h + 8), duration: 2, 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, overwrite: 'auto' }, 0); |
| } |
| tlRefs.current[index] = tl; |
| }); |
| }; |
|
|
| layout(); |
| window.addEventListener('resize', layout); |
| document.fonts?.ready?.then(layout).catch(() => {}); |
|
|
| const menu = mobileMenuRef.current; |
| if (menu) gsap.set(menu, { visibility: 'hidden', opacity: 0, scaleY: 1 }); |
|
|
| if (initialLoadAnimation) { |
| const logoEl = logoRef.current; |
| const navItems = navItemsRef.current; |
| if (logoEl) { gsap.set(logoEl, { scale: 0 }); gsap.to(logoEl, { scale: 1, duration: 0.6, ease }); } |
| if (navItems) { gsap.set(navItems, { width: 0, overflow: 'hidden' }); gsap.to(navItems, { width: 'auto', duration: 0.6, ease }); } |
| } |
|
|
| return () => window.removeEventListener('resize', layout); |
| }, [items, ease, initialLoadAnimation]); |
|
|
| const handleEnter = (i: number) => { |
| const tl = tlRefs.current[i]; |
| if (!tl) return; |
| activeTweenRefs.current[i]?.kill(); |
| activeTweenRefs.current[i] = tl.tweenTo(tl.duration(), { duration: 0.3, ease, overwrite: 'auto' }) as gsap.core.Tween; |
| }; |
|
|
| const handleLeave = (i: number) => { |
| const tl = tlRefs.current[i]; |
| if (!tl) return; |
| activeTweenRefs.current[i]?.kill(); |
| activeTweenRefs.current[i] = tl.tweenTo(0, { duration: 0.2, ease, overwrite: 'auto' }) as gsap.core.Tween; |
| }; |
|
|
| const handleLogoEnter = () => { |
| const img = logoImgRef.current; |
| if (!img) return; |
| logoTweenRef.current?.kill(); |
| gsap.set(img, { rotate: 0 }); |
| logoTweenRef.current = gsap.to(img, { rotate: 360, duration: 0.4, ease, overwrite: 'auto' }); |
| }; |
|
|
| const toggleMobileMenu = () => { |
| const newState = !isMobileMenuOpen; |
| setIsMobileMenuOpen(newState); |
| const hamburger = hamburgerRef.current; |
| const menu = mobileMenuRef.current; |
|
|
| if (hamburger) { |
| const lines = hamburger.querySelectorAll('.hamburger-line'); |
| if (newState) { |
| gsap.to(lines[0], { rotation: 45, y: 3, duration: 0.3, ease }); |
| gsap.to(lines[1], { rotation: -45, y: -3, duration: 0.3, ease }); |
| } else { |
| gsap.to(lines[0], { rotation: 0, y: 0, duration: 0.3, ease }); |
| gsap.to(lines[1], { rotation: 0, y: 0, duration: 0.3, ease }); |
| } |
| } |
|
|
| if (menu) { |
| if (newState) { |
| gsap.set(menu, { visibility: 'visible' }); |
| gsap.fromTo(menu, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: 0.3, ease }); |
| } else { |
| gsap.to(menu, { opacity: 0, y: 10, duration: 0.2, ease, onComplete: () => gsap.set(menu, { visibility: 'hidden' }) }); |
| } |
| } |
| onMobileMenuClick?.(); |
| }; |
|
|
| const cssVars = { |
| ['--base']: baseColor, |
| ['--pill-bg']: pillColor, |
| ['--hover-text']: hoveredPillTextColor, |
| ['--pill-text']: resolvedPillTextColor, |
| } as React.CSSProperties; |
|
|
| const location = useLocation(); |
| const currentHref = activeHref ?? location.pathname; |
|
|
| const renderPill = (item: PillNavItem, i: number) => { |
| const isActive = item.href ? currentHref === item.href : false; |
| const pillClass = `pill${isActive ? ' is-active' : ''}`; |
| const inner = ( |
| <> |
| <span className="hover-circle" aria-hidden="true" ref={el => { circleRefs.current[i] = el; }} /> |
| <span className="label-stack"> |
| <span className="pill-label">{item.label}</span> |
| <span className="pill-label-hover" aria-hidden="true">{item.label}</span> |
| </span> |
| </> |
| ); |
|
|
| if (item.onClick) { |
| return ( |
| <button role="menuitem" className={pillClass} |
| aria-label={item.ariaLabel || item.label} |
| onMouseEnter={() => handleEnter(i)} onMouseLeave={() => handleLeave(i)} |
| onClick={item.onClick}> |
| {inner} |
| </button> |
| ); |
| } |
|
|
| const isInternal = item.href && !item.href.startsWith('http') && !item.href.startsWith('mailto'); |
| if (isInternal) { |
| return ( |
| <Link role="menuitem" to={item.href!} className={pillClass} |
| aria-label={item.ariaLabel || item.label} |
| onMouseEnter={() => handleEnter(i)} onMouseLeave={() => handleLeave(i)}> |
| {inner} |
| </Link> |
| ); |
| } |
|
|
| return ( |
| <a role="menuitem" href={item.href || '#'} className={pillClass} |
| aria-label={item.ariaLabel || item.label} |
| onMouseEnter={() => handleEnter(i)} onMouseLeave={() => handleLeave(i)}> |
| {inner} |
| </a> |
| ); |
| }; |
|
|
| return ( |
| <div className="pill-nav-container"> |
| <nav className={`pill-nav ${className}`} aria-label="Primary" style={cssVars}> |
| {/* Logo / brand */} |
| <a |
| className="pill-logo" |
| href="#" |
| aria-label="Home" |
| onMouseEnter={handleLogoEnter} |
| ref={logoRef} |
| style={{ background: baseColor, color: resolvedPillTextColor }} |
| > |
| {logo |
| ? <img src={logo} alt={logoAlt} ref={logoImgRef} /> |
| : <span style={{ fontWeight: 800, fontSize: 14, letterSpacing: '0.05em', color: '#c084fc' }}>{logoText ?? 'AUTHRIX'}</span> |
| } |
| </a> |
| |
| {/* Desktop nav items */} |
| <div className="pill-nav-items desktop-only" ref={navItemsRef}> |
| <ul className="pill-list" role="menubar"> |
| {items.map((item, i) => ( |
| <li key={i} role="none">{renderPill(item, i)}</li> |
| ))} |
| </ul> |
| </div> |
| |
| {/* Mobile hamburger */} |
| <button |
| className="mobile-menu-button mobile-only" |
| onClick={toggleMobileMenu} |
| aria-label="Toggle menu" |
| ref={hamburgerRef} |
| > |
| <span className="hamburger-line" /> |
| <span className="hamburger-line" /> |
| </button> |
| </nav> |
| |
| {/* Mobile popover */} |
| <div className="mobile-menu-popover mobile-only" ref={mobileMenuRef} style={cssVars}> |
| <ul className="mobile-menu-list"> |
| {items.map((item, i) => ( |
| <li key={i}> |
| {item.onClick |
| ? <button className={`mobile-menu-link${currentHref === item.href ? ' is-active' : ''}`} onClick={() => { item.onClick?.(); setIsMobileMenuOpen(false); }}>{item.label}</button> |
| : item.href && !item.href.startsWith('http') |
| ? <Link to={item.href} className={`mobile-menu-link${currentHref === item.href ? ' is-active' : ''}`} onClick={() => setIsMobileMenuOpen(false)}>{item.label}</Link> |
| : <a href={item.href || '#'} className={`mobile-menu-link${currentHref === item.href ? ' is-active' : ''}`} onClick={() => setIsMobileMenuOpen(false)}>{item.label}</a> |
| } |
| </li> |
| ))} |
| </ul> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| export default PillNav; |
|
|