"use client";
import * as React from "react";
import {
AnimatePresence,
LayoutGroup,
motion,
useMotionValueEvent,
useReducedMotion,
useScroll,
} from "motion/react";
import {
ArrowRight,
BarChart3,
Blocks,
ChevronDown,
Cpu,
ShieldCheck,
Workflow,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface NavbarFloatingLink {
label: string;
href?: string;
}
export interface NavbarFloatingProduct {
title: string;
description: string;
icon: LucideIcon;
href?: string;
}
export interface NavbarFloatingProps {
brand?: string;
brandHref?: string;
/** Top-level links shown after the Products dropdown. */
links?: NavbarFloatingLink[];
/** Label of the dropdown trigger. */
productsLabel?: string;
products?: NavbarFloatingProduct[];
cta?: NavbarFloatingLink;
signIn?: NavbarFloatingLink;
/** Scroll distance (px) after which the bar shrinks. */
threshold?: number;
/** `fixed` (default) floats over the page; `sticky` stays in the flow. */
position?: "fixed" | "sticky";
className?: string;
}
const DEFAULT_PRODUCTS: NavbarFloatingProduct[] = [
{ title: "Analytics", description: "Realtime dashboards for every team", icon: BarChart3, href: "#" },
{ title: "Automations", description: "Trigger workflows from any event", icon: Workflow, href: "#" },
{ title: "Integrations", description: "120+ connectors, zero glue code", icon: Blocks, href: "#" },
{ title: "Edge compute", description: "Run logic 20ms from every user", icon: Cpu, href: "#" },
{ title: "Security", description: "SSO, audit logs and SOC 2 reports", icon: ShieldCheck, href: "#" },
];
const DEFAULT_LINKS: NavbarFloatingLink[] = [
{ label: "Customers", href: "#" },
{ label: "Pricing", href: "#" },
{ label: "Changelog", href: "#" },
{ label: "Docs", href: "#" },
];
const SPRING = { type: "spring", stiffness: 380, damping: 32 } as const;
export function NavbarFloating({
brand = "Orbit",
brandHref = "#",
links = DEFAULT_LINKS,
productsLabel = "Products",
products = DEFAULT_PRODUCTS,
cta = { label: "Get started", href: "#" },
signIn = { label: "Log in", href: "#" },
threshold = 24,
position = "fixed",
className,
}: NavbarFloatingProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const { scrollY } = useScroll();
const [scrolled, setScrolled] = React.useState(false);
const [hovered, setHovered] = React.useState<string | null>(null);
const [active, setActive] = React.useState<string>(links[0]?.label ?? "");
const [dropdown, setDropdown] = React.useState(false);
const [mobile, setMobile] = React.useState(false);
const dropRef = React.useRef<HTMLLIElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
useMotionValueEvent(scrollY, "change", (v) => setScrolled(v > threshold));
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync with the initial scroll offset after mount
setScrolled(window.scrollY > threshold);
}, [threshold]);
// Close dropdown on outside click / Escape.
React.useEffect(() => {
if (!dropdown) return;
const onDown = (e: PointerEvent) => {
if (!dropRef.current?.contains(e.target as Node)) setDropdown(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setDropdown(false);
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [dropdown]);
// Mobile sheet: Escape closes, body scroll locked.
React.useEffect(() => {
if (!mobile) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMobile(false);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
document.removeEventListener("keydown", onKey);
};
}, [mobile]);
const openDrop = () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
setDropdown(true);
};
const scheduleClose = () => {
closeTimer.current = setTimeout(() => setDropdown(false), 140);
};
const pill = hovered ?? active;
const panelId = `${uid}-products`;
const sheetId = `${uid}-sheet`;
return (
<header
className={cn(
"pointer-events-none inset-x-0 top-0 z-50 flex w-full justify-center px-3 sm:px-4",
position === "fixed" ? "fixed" : "sticky",
className,
)}
>
<motion.nav
aria-label="Main"
initial={false}
animate={{ maxWidth: scrolled ? 860 : 1180, marginTop: scrolled ? 12 : 16, height: scrolled ? 54 : 64 }}
transition={reduce ? { duration: 0 } : SPRING}
className={cn(
"pointer-events-auto relative flex w-full items-center gap-2 rounded-full border pl-4 pr-2 transition-[background-color,border-color,box-shadow] duration-300",
scrolled || mobile
? "border-foreground/10 bg-background/70 shadow-lg shadow-black/5 backdrop-blur-xl backdrop-saturate-150 dark:shadow-black/30"
: "border-transparent bg-transparent",
)}
>
<a href={brandHref} className="flex shrink-0 items-center gap-2 rounded-full pr-2 font-semibold tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<BrandMark />
<span>{brand}</span>
</a>
{/* Desktop links */}
<LayoutGroup id={uid}>
<ul className="mx-auto hidden items-center md:flex" onMouseLeave={() => setHovered(null)}>
<li ref={dropRef} className="relative" onMouseEnter={openDrop} onMouseLeave={scheduleClose}>
<button
ref={triggerRef}
type="button"
aria-expanded={dropdown}
aria-controls={panelId}
onClick={() => setDropdown((v) => !v)}
onMouseEnter={() => setHovered(productsLabel)}
onFocus={() => setHovered(productsLabel)}
onBlur={() => setHovered(null)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setDropdown(true);
requestAnimationFrame(() => document.getElementById(panelId)?.querySelector("a")?.focus());
}
}}
className="relative flex items-center gap-1 rounded-full px-3.5 py-2 text-sm font-medium text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-expanded:text-foreground"
>
{pill === productsLabel && <Pill layoutId="pill" />}
<span className="relative">{productsLabel}</span>
<ChevronDown className={cn("relative size-3.5 transition-transform duration-200", dropdown && "rotate-180")} aria-hidden />
</button>
<AnimatePresence>
{dropdown && (
<motion.div
id={panelId}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97, filter: "blur(4px)" }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
style={{ transformOrigin: "20% 0%" }}
className="absolute left-0 top-full pt-3"
>
<div className="w-[540px] overflow-hidden rounded-2xl border border-foreground/10 bg-popover text-popover-foreground shadow-2xl shadow-black/10">
<ul className="grid grid-cols-2 gap-1 p-2">
{products.map((p, i) => (
<motion.li
key={p.title}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.03 * i, duration: 0.25 }}
>
<a
href={p.href ?? "#"}
onClick={() => setDropdown(false)}
className="group flex gap-3 rounded-xl p-3 transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg border bg-background text-foreground/80 transition-colors group-hover:border-primary/30 group-hover:text-primary">
<p.icon className="size-4" aria-hidden />
</span>
<span>
<span className="block text-sm font-medium">{p.title}</span>
<span className="block text-xs leading-snug text-muted-foreground">{p.description}</span>
</span>
</a>
</motion.li>
))}
<li className="flex">
<a
href="#"
className="group flex flex-1 flex-col justify-between rounded-xl bg-linear-to-br from-primary/15 via-primary/5 to-transparent p-3 transition-colors hover:from-primary/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="text-xs font-medium text-primary">New</span>
<span className="flex items-center gap-1 text-sm font-medium">
See the 2026 roadmap
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" aria-hidden />
</span>
</a>
</li>
</ul>
</div>
</motion.div>
)}
</AnimatePresence>
</li>
{links.map((l) => (
<li key={l.label}>
<a
href={l.href ?? "#"}
aria-current={active === l.label ? "page" : undefined}
onClick={() => setActive(l.label)}
onMouseEnter={() => setHovered(l.label)}
onFocus={() => setHovered(l.label)}
onBlur={() => setHovered(null)}
className={cn(
"relative block rounded-full px-3.5 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active === l.label || pill === l.label ? "text-foreground" : "text-foreground/70",
)}
>
{pill === l.label && <Pill layoutId="pill" />}
<span className="relative">{l.label}</span>
{active === l.label && (
<motion.span layoutId="dot" transition={SPRING} className="absolute bottom-0.5 left-1/2 size-1 -translate-x-1/2 rounded-full bg-primary" />
)}
</a>
</li>
))}
</ul>
</LayoutGroup>
<div className="ml-auto hidden items-center gap-1 md:flex">
<a href={signIn.href ?? "#"} className="rounded-full px-3.5 py-2 text-sm font-medium text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{signIn.label}
</a>
<a
href={cta.href ?? "#"}
className="group inline-flex h-10 items-center gap-1.5 rounded-full bg-foreground px-4 text-sm font-semibold text-background transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{cta.label}
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" aria-hidden />
</a>
</div>
{/* Mobile toggle */}
<button
type="button"
aria-label={mobile ? "Close menu" : "Open menu"}
aria-expanded={mobile}
aria-controls={sheetId}
onClick={() => setMobile((v) => !v)}
className="ml-auto grid size-10 place-items-center rounded-full transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
>
<span className="relative block h-3 w-4" aria-hidden>
<motion.span
className="absolute left-0 top-0 h-0.5 w-4 rounded-full bg-foreground"
animate={mobile ? { top: 5, rotate: 45 } : { top: 0, rotate: 0 }}
transition={{ duration: 0.25 }}
/>
<motion.span
className="absolute left-0 top-2.5 h-0.5 w-4 rounded-full bg-foreground"
animate={mobile ? { top: 5, rotate: -45 } : { top: 10, rotate: 0 }}
transition={{ duration: 0.25 }}
/>
</span>
</button>
</motion.nav>
{/* Mobile sheet */}
<AnimatePresence>
{mobile && (
<>
<motion.div
key="backdrop"
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setMobile(false)}
className="pointer-events-auto fixed inset-0 -z-10 bg-background/60 backdrop-blur-sm md:hidden"
/>
<motion.div
key="sheet"
id={sheetId}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -12, scale: 0.98 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
style={{ transformOrigin: "50% 0%" }}
className="pointer-events-auto fixed inset-x-3 top-[84px] max-h-[calc(100dvh-100px)] overflow-y-auto rounded-3xl border border-foreground/10 bg-popover p-3 text-popover-foreground shadow-2xl md:hidden"
>
<p className="px-3 pb-1 pt-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">{productsLabel}</p>
<ul className="grid gap-0.5">
{products.map((p, i) => (
<motion.li key={p.title} initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.04 * i + 0.05 }}>
<a href={p.href ?? "#"} onClick={() => setMobile(false)} className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none">
<span className="grid size-8 place-items-center rounded-lg border bg-background">
<p.icon className="size-4" aria-hidden />
</span>
<span className="text-sm font-medium">{p.title}</span>
</a>
</motion.li>
))}
</ul>
<div className="my-2 h-px bg-border" />
<ul className="grid gap-0.5">
{links.map((l, i) => (
<motion.li key={l.label} initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.04 * (i + products.length) + 0.05 }}>
<a
href={l.href ?? "#"}
onClick={() => (setActive(l.label), setMobile(false))}
className="flex items-center justify-between rounded-xl px-3 py-3 text-base font-medium transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
>
{l.label}
{active === l.label && <span className="size-1.5 rounded-full bg-primary" />}
</a>
</motion.li>
))}
</ul>
<div className="mt-3 grid grid-cols-2 gap-2">
<a href={signIn.href ?? "#"} className="inline-flex h-11 items-center justify-center rounded-full border text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{signIn.label}
</a>
<a href={cta.href ?? "#"} className="inline-flex h-11 items-center justify-center rounded-full bg-foreground text-sm font-semibold text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{cta.label}
</a>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</header>
);
}
function Pill({ layoutId }: { layoutId: string }) {
return <motion.span layoutId={layoutId} transition={SPRING} className="absolute inset-0 rounded-full bg-foreground/[0.06] dark:bg-foreground/10" />;
}
function BrandMark() {
return (
<svg viewBox="0 0 24 24" className="size-7" aria-hidden>
<defs>
<linearGradient id="nf-brand" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#06b6d4" />
</linearGradient>
</defs>
<rect width="24" height="24" rx="7" fill="url(#nf-brand)" />
<ellipse cx="12" cy="12" rx="7.5" ry="3.2" fill="none" stroke="#fff" strokeWidth="1.6" transform="rotate(-28 12 12)" />
<circle cx="12" cy="12" r="2.6" fill="#fff" />
</svg>
);
}