Download frontend-react/src/components/PillNav.tsx from Aarav13/AuthriX: direct link, hf CLI and curl.
- Browser
- Download file 9.56 kB
-
https://huggingface.co/spaces/Aarav13/AuthriX/resolve/main/frontend-react/src/components/PillNav.tsx
- Command line
-
hf download hf://spaces/Aarav13/AuthriX/frontend-react/src/components/PillNav.tsx
-
curl -L -o PillNav.tsx https://huggingface.co/spaces/Aarav13/AuthriX/resolve/main/frontend-react/src/components/PillNav.tsx
9.56 kB
| 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; | |