"use client"; import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { ArrowUpRight, Menu, X } from "lucide-react"; import { cn } from "@/lib/utils"; gsap.registerPlugin(useGSAP); export interface CurvedNavbarItem { label: string; href: string; description?: string; } export interface CurvedNavbarProps { brand?: string; brandHref?: string; logo?: ReactNode; logoSrc?: string; items?: readonly CurvedNavbarItem[]; actionLabel?: string; actionHref?: string; panelHeading?: string; panelDescription?: string; children?: ReactNode; duration?: number; stiffness?: number; closeDelay?: number; compactWidth?: number; expandedWidth?: number; curveRadius?: number; background?: string; foreground?: string; accent?: string; openOnHover?: boolean; onOpenChange?: (open: boolean) => void; className?: string; } export const CURVED_NAVBAR_ITEMS: readonly CurvedNavbarItem[] = [ { label: "Features", href: "#features", description: "Built for your everyday flow.", }, { label: "Stories", href: "#stories", description: "A little inspiration, every day.", }, { label: "Updates", href: "#updates", description: "See what we have been working on.", }, { label: "About", href: "#about", description: "The people behind the details.", }, ]; const bounded = (value: number, min: number, max: number, fallback: number) => Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; export function CurvedNavbar({ brand = "devclub", brandHref = "#home", logo, logoSrc = "https://i.pinimg.com/736x/16/16/72/16167292c79a7d3ca27ef4f94d1b7424.jpg", items = CURVED_NAVBAR_ITEMS, actionLabel = "Get started", actionHref = "#get-started", panelHeading = "A space for everything.", panelDescription = "Explore the details. Find your next perspective.", children, duration = 0.6, stiffness = 0.35, closeDelay = 0.12, compactWidth = 880, expandedWidth = 1040, curveRadius = 26, background = "#080808", foreground = "#f5f5f5", accent = "#b9f582", openOnHover = true, onOpenChange, className, }: CurvedNavbarProps) { const frameRef = useRef(null); const shellRef = useRef(null); const panelRef = useRef(null); const contentRef = useRef(null); const triggerRef = useRef(null); const timelineRef = useRef(null); const closeRef = useRef(null); const animateRef = useRef<((expanded: boolean) => void) | null>(null); const openRef = useRef(false); const changeRef = useRef(onOpenChange); const [open, setOpen] = useState(false); const panelId = useId(); const radius = bounded(curveRadius, 12, 40, 26); const speed = bounded(duration, 0.15, 1.5, 0.6); const spring = bounded(stiffness, 0, 1, 0.35); const delay = bounded(closeDelay, 0, 0.6, 0.12); const narrowWidth = bounded(compactWidth, 320, 1400, 880); const wideWidth = Math.max( narrowWidth, bounded(expandedWidth, 320, 1600, 1040), ); useEffect(() => { changeRef.current = onOpenChange; }, [onOpenChange]); const updateOpen = (next: boolean) => { closeRef.current?.kill(); if (openRef.current === next) return; openRef.current = next; setOpen(next); changeRef.current?.(next); }; useGSAP( () => { const frame = frameRef.current; const shell = shellRef.current; const panel = panelRef.current; const content = contentRef.current; if (!frame || !shell || !panel || !content) return; const media = gsap.matchMedia(); media.add( { reduced: "(prefers-reduced-motion: reduce)", all: "all" }, (context) => { const reduced = Boolean(context.conditions?.reduced); let needsResize = false; const build = context.add("build", () => { needsResize = false; const wasOpen = openRef.current; timelineRef.current?.kill(); const available = frame.clientWidth; const collapsed = Math.min( narrowWidth, Math.max(0, available - 48), ); const mobile = available < 680; const viewportHeight = Math.max( 64, frame.parentElement?.clientHeight ?? 0, ); const fullHeight = viewportHeight > 80 ? viewportHeight : window.innerHeight; const expanded = mobile ? available : Math.min(wideWidth, Math.max(0, available - 32)); gsap.set(content, { maxHeight: mobile ? Math.max(0, fullHeight - 64) : "min(440px,65vh)", }); const entries = content.querySelectorAll("[data-navbar-entry]"); gsap.set(shell, { width: expanded }); const expandedHeight = mobile ? Math.max(0, fullHeight - 64) : content.offsetHeight; gsap.set(shell, { width: collapsed, top: 0, borderBottomLeftRadius: radius, borderBottomRightRadius: radius, }); gsap.set(panel, { height: 0, autoAlpha: 0 }); gsap.set(entries, { y: reduced ? 0 : 12, opacity: 0 }); const timeline = gsap .timeline({ paused: true, onComplete: () => { if (needsResize) build(); }, onReverseComplete: () => { if (needsResize) build(); }, }) .to( shell, { width: expanded, top: mobile ? -frame.offsetTop : 0, borderBottomLeftRadius: mobile ? 0 : radius, borderBottomRightRadius: mobile ? 0 : radius, duration: reduced ? 0 : speed, ease: spring === 0 ? "power3.out" : `back.out(${spring * 0.8})`, }, 0, ) .to( panel, { height: expandedHeight, autoAlpha: 1, duration: reduced ? 0 : speed, ease: "power3.inOut", }, 0, ) .to( entries, { y: 0, opacity: 1, duration: reduced ? 0 : speed * 0.55, stagger: reduced ? 0 : Math.min(0.035, speed / 12), ease: "power3.out", }, reduced ? 0 : speed * 0.22, ); timelineRef.current = timeline; if (reduced) animate(wasOpen); else if (wasOpen) timeline.progress(1); }); const animate = context.add("animate", (next: boolean) => { if (reduced) { const available = frame.clientWidth; const mobile = available < 680; const viewportHeight = Math.max( 64, frame.parentElement?.clientHeight ?? 0, ); const fullHeight = viewportHeight > 80 ? viewportHeight : window.innerHeight; gsap.set(shell, { width: next && mobile ? available : Math.min( next ? wideWidth : narrowWidth, Math.max(0, available - (next ? 32 : 48)), ), top: next && mobile ? -frame.offsetTop : 0, borderBottomLeftRadius: next && mobile ? 0 : radius, borderBottomRightRadius: next && mobile ? 0 : radius, }); gsap.set(panel, { height: next ? mobile ? Math.max(0, fullHeight - 64) : content.offsetHeight : 0, autoAlpha: next ? 1 : 0, }); gsap.set(content.querySelectorAll("[data-navbar-entry]"), { y: 0, opacity: next ? 1 : 0, }); } else if (next) { timelineRef.current?.play(); } else { timelineRef.current?.reverse(); } }); animateRef.current = (next) => animate(next); const leave = context.add("leave", (event: PointerEvent) => { if (event.pointerType !== "mouse" || frame.clientWidth < 680) return; if (shell.contains(document.activeElement)) return; closeRef.current?.kill(); closeRef.current = gsap.delayedCall(delay, () => updateOpen(false)); }); const handleLeave = (event: PointerEvent) => leave(event); shell.addEventListener("pointerleave", handleLeave); build(); const observer = new ResizeObserver(() => { const timeline = timelineRef.current; if (timeline?.isActive()) { needsResize = true; return; } build(); }); observer.observe(frame); if (frame.parentElement) observer.observe(frame.parentElement); observer.observe(content); return () => { observer.disconnect(); shell.removeEventListener("pointerleave", handleLeave); closeRef.current?.kill(); timelineRef.current?.kill(); timelineRef.current = null; animateRef.current = null; }; }, ); return () => media.revert(); }, { scope: frameRef, dependencies: [ speed, spring, delay, radius, narrowWidth, wideWidth, items, children, ], revertOnUpdate: true, }, ); useGSAP( () => { closeRef.current?.kill(); animateRef.current?.(open); }, { scope: frameRef, dependencies: [open] }, ); useEffect(() => { if (!open) return; const outside = (event: PointerEvent) => { if ( event.target instanceof Node && !shellRef.current?.contains(event.target) ) { openRef.current = false; setOpen(false); changeRef.current?.(false); } }; document.addEventListener("pointerdown", outside); return () => document.removeEventListener("pointerdown", outside); }, [open]); return (
); } export default CurvedNavbar;