Marketing top nav with a retargeting hover pill on desktop links, a staggered mobile disclosure panel, and scroll-aware chrome.
npx shadcn@latest add @paragon/navbar"use client";
import * as React from "react";
import { Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
interface NavbarContextValue {
open: boolean;
setOpen: (open: boolean) => void;
panelId: string;
}
const NavbarContext = React.createContext<NavbarContextValue | null>(null);
function useNavbar() {
const context = React.useContext(NavbarContext);
if (!context) {
throw new Error("Navbar parts must be used within <Navbar>");
}
return context;
}
export interface NavbarProps extends React.ComponentProps<"header"> {}
/**
* Marketing top nav. A border + backdrop blur fade in once the page scrolls
* past 8px (tracked with a sentinel IntersectionObserver, so it works inside
* any scroll container). Desktop links share a hover pill that retargets as
* the pointer moves between them; the mobile panel discloses with
* grid-template-rows and staggered link fade-ins. Escape closes the panel.
*/
export function Navbar({ className, children, ...props }: NavbarProps) {
const [scrolled, setScrolled] = React.useState(false);
const [open, setOpen] = React.useState(false);
const sentinelRef = React.useRef<HTMLSpanElement>(null);
const headerRef = React.useRef<HTMLElement>(null);
const panelId = React.useId();
React.useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => setScrolled(!entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
headerRef.current
?.querySelector<HTMLElement>("[data-navbar-toggle]")
?.focus();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
const contextValue = React.useMemo(
() => ({ open, setOpen, panelId }),
[open, panelId],
);
return (
<NavbarContext.Provider value={contextValue}>
{/* 8px-tall sentinel: once it fully scrolls out, the chrome appears. */}
<span aria-hidden ref={sentinelRef} className="-mb-2 block h-2 w-px" />
<header
ref={headerRef}
data-slot="navbar"
data-scrolled={scrolled || undefined}
className={cn(
"sticky top-0 z-40 border-b border-transparent transition-[border-color,background-color] duration-200 ease-out data-scrolled:border-border data-scrolled:bg-background/80 data-scrolled:backdrop-blur-lg",
className,
)}
{...props}
>
<div className="relative flex h-14 items-center gap-6 px-4 md:px-6">
{children}
</div>
</header>
</NavbarContext.Provider>
);
}
export function NavbarBrand({
className,
...props
}: React.ComponentProps<"a">) {
return (
<a
data-slot="navbar-brand"
className={cn(
"flex shrink-0 items-center gap-2 rounded-md text-sm font-semibold text-foreground",
className,
)}
{...props}
/>
);
}
export function NavbarLinks({
className,
children,
...props
}: React.ComponentProps<"nav">) {
const pillRef = React.useRef<HTMLSpanElement>(null);
const visibleRef = React.useRef(false);
const show = (link: HTMLElement) => {
const pill = pillRef.current;
if (!pill) return;
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
return;
}
const x = link.offsetLeft;
const width = link.offsetWidth;
if (!visibleRef.current) {
// First appearance: land in place and only fade — never slide in
// from a stale position.
pill.style.transitionProperty = "opacity";
pill.style.transform = `translateX(${x}px)`;
pill.style.width = `${width}px`;
void pill.offsetWidth;
pill.style.transitionProperty = "";
visibleRef.current = true;
} else {
pill.style.transform = `translateX(${x}px)`;
pill.style.width = `${width}px`;
}
pill.style.opacity = "1";
};
const hide = () => {
const pill = pillRef.current;
if (!pill) return;
pill.style.opacity = "0";
visibleRef.current = false;
};
return (
<nav
data-slot="navbar-links"
onMouseOver={(event) => {
const link = (event.target as HTMLElement).closest("a");
if (link && event.currentTarget.contains(link)) show(link);
}}
onMouseLeave={hide}
className={cn("relative hidden items-center gap-1 md:flex", className)}
{...props}
>
<span
ref={pillRef}
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 my-auto h-8 rounded-md bg-accent opacity-0 [transition-property:transform,width,opacity] duration-200 ease-out motion-reduce:transition-none"
/>
{children}
</nav>
);
}
export interface NavbarLinkProps extends React.ComponentProps<"a"> {
active?: boolean;
}
export function NavbarLink({ active, className, ...props }: NavbarLinkProps) {
return (
<a
data-slot="navbar-link"
data-active={active || undefined}
className={cn(
"relative z-10 flex h-8 items-center rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground data-active:text-foreground",
className,
)}
{...props}
/>
);
}
export function NavbarActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="navbar-actions"
className={cn("ml-auto flex items-center gap-2", className)}
{...props}
/>
);
}
export function NavbarMobile({
className,
children,
...props
}: React.ComponentProps<"div">) {
const { open, setOpen, panelId } = useNavbar();
const items = React.Children.toArray(children);
return (
<div className={cn("md:hidden", className)} {...props}>
<button
type="button"
data-navbar-toggle=""
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? "Close menu" : "Open menu"}
onClick={() => setOpen(!open)}
className="pressable relative flex size-9 items-center justify-center rounded-md text-foreground transition-colors duration-150 ease-out hover:bg-accent"
>
<Menu
aria-hidden
className={cn(
"absolute size-5 transition-[opacity,rotate] duration-200 ease-out motion-reduce:transition-none",
open && "rotate-90 opacity-0",
)}
/>
<X
aria-hidden
className={cn(
"absolute size-5 transition-[opacity,rotate] duration-200 ease-out motion-reduce:transition-none",
!open && "-rotate-90 opacity-0",
)}
/>
</button>
<div
id={panelId}
inert={!open}
data-open={open}
className="absolute inset-x-0 top-full grid [grid-template-rows:0fr] border-b border-transparent bg-background/95 backdrop-blur-lg transition-[grid-template-rows,border-color] duration-[250ms] ease-out data-[open=true]:[grid-template-rows:1fr] data-[open=true]:border-border motion-reduce:transition-none md:hidden"
>
<div className="min-h-0 overflow-hidden">
<div className="flex flex-col gap-0.5 px-4 py-3">
{items.map((child, index) => (
<div
key={index}
style={{ transitionDelay: open ? `${index * 40}ms` : "0ms" }}
className={cn(
"transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none",
open ? "translate-y-0 opacity-100" : "-translate-y-1 opacity-0",
)}
>
{child}
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function NavbarMobileLink({
active,
className,
...props
}: NavbarLinkProps) {
return (
<a
data-slot="navbar-mobile-link"
data-active={active || undefined}
className={cn(
"flex h-10 items-center rounded-md px-2 text-sm font-medium text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-foreground data-active:text-foreground",
className,
)}
{...props}
/>
);
}