Navigation

Navbar

Marketing top nav with a retargeting hover pill on desktop links, a staggered mobile disclosure panel, and scroll-aware chrome.

Install

npx shadcn@latest add @paragon/navbar

navbar.tsx

"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}
    />
  );
}