deepmoon
All componentsnavigation
deepmoon

Navbar

Scroll down: the bar above starts transparent over this hero and switches to solid, bordered, and blurred once past the threshold. Resize under ~768px for the mobile drawer trigger, and click "Product" for the mega menu.

Past the threshold

The navbar is now solid with a border and backdrop blur.

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/navbar.json

npm dependencies: lucide-react

Props

NavbarProps
PropTypeRequired
scrollThresholdnumberNo
stickybooleanNo
NavbarLinksProps
PropTypeRequired
linksNavbarLink[]Yes

Code

navbar.tsx
"use client";

import * as React from "react";
import { ChevronDown, Menu, X } from "lucide-react";

import { cn } from "@/lib/utils";

const DRAWER_ID = "navbar-mobile-drawer";

interface NavbarMobileMenuContextValue {
  open: boolean;
  setOpen: (open: boolean) => void;
}

const NavbarMobileMenuContext = React.createContext<NavbarMobileMenuContextValue | null>(null);

function useNavbarMobileMenu(component: string) {
  const context = React.useContext(NavbarMobileMenuContext);
  if (!context) {
    throw new Error(`<${component}> must be used within <Navbar>.`);
  }
  return context;
}

function getFocusable(container: HTMLElement): HTMLElement[] {
  return Array.from(
    container.querySelectorAll<HTMLElement>(
      'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
    ),
  ).filter((el) => el.offsetParent !== null);
}

/** Traps Tab/Shift+Tab inside `containerRef` while `active`, wrapping at the ends. */
function useFocusTrap(active: boolean, containerRef: React.RefObject<HTMLElement | null>) {
  React.useEffect(() => {
    if (!active || !containerRef.current) return;
    const container = containerRef.current;
    getFocusable(container)[0]?.focus();

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key !== "Tab" || !container) return;
      const items = getFocusable(container);
      if (items.length === 0) return;
      const first = items[0];
      const last = items[items.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    container.addEventListener("keydown", handleKeyDown);
    return () => container.removeEventListener("keydown", handleKeyDown);
  }, [active, containerRef]);
}

export interface NavbarProps extends React.ComponentPropsWithoutRef<"header"> {
  /** Pixels scrolled before switching from transparent to solid. */
  scrollThreshold?: number;
  sticky?: boolean;
}

/**
 * Root <header>, wrapping a max-width inner row that holds whatever slots
 * are passed as children (NavbarLogo, NavbarLinks, NavbarActions,
 * NavbarMobileTrigger). Tracks scroll position to switch from transparent
 * to a solid, bordered background; that transition is explicitly
 * motion-safe-gated, since it fires continuously while the page scrolls.
 */
export function Navbar({ scrollThreshold = 8, sticky = true, className, children, ...props }: NavbarProps) {
  const [scrolled, setScrolled] = React.useState(false);
  const [mobileOpen, setMobileOpen] = React.useState(false);

  React.useEffect(() => {
    function handleScroll() {
      setScrolled(window.scrollY > scrollThreshold);
    }
    handleScroll();
    window.addEventListener("scroll", handleScroll, { passive: true });
    return () => window.removeEventListener("scroll", handleScroll);
  }, [scrollThreshold]);

  return (
    <NavbarMobileMenuContext.Provider value={{ open: mobileOpen, setOpen: setMobileOpen }}>
      <header
        data-scrolled={scrolled || undefined}
        className={cn(
          "top-0 z-40 w-full border-b border-transparent motion-safe:transition-colors motion-safe:duration-200",
          sticky && "sticky",
          scrolled ? "border-border bg-background/95 backdrop-blur" : "bg-transparent",
          className,
        )}
        {...props}
      >
        <div className="mx-auto flex h-14 w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-8" style={{ maxWidth: "var(--container-max-wide)" }}>
          {children}
        </div>
      </header>
    </NavbarMobileMenuContext.Provider>
  );
}

export function NavbarLogo({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
  return (
    <div
      className={cn("flex shrink-0 items-center gap-2 text-base font-semibold text-foreground", className)}
      {...props}
    />
  );
}

export interface NavbarLink {
  label: string;
  href: string;
  /** Rich dropdown content — treated as a disclosure panel, not an ARIA menu, since content is arbitrary. */
  megaMenu?: React.ReactNode;
}

export interface NavbarLinksProps extends Omit<React.ComponentPropsWithoutRef<"nav">, "children"> {
  links: NavbarLink[];
}

export function NavbarLinks({ links, className, ...props }: NavbarLinksProps) {
  const [openMenu, setOpenMenu] = React.useState<number | null>(null);
  const rootRef = React.useRef<HTMLElement>(null);

  React.useEffect(() => {
    function handlePointerDown(event: PointerEvent) {
      if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpenMenu(null);
    }
    document.addEventListener("pointerdown", handlePointerDown);
    return () => document.removeEventListener("pointerdown", handlePointerDown);
  }, []);

  return (
    <nav ref={rootRef} aria-label="Main" className={cn("hidden items-center gap-1 md:flex", className)} {...props}>
      {links.map((link, index) => {
        if (!link.megaMenu) {
          return (
            <a
              key={index}
              href={link.href}
              className="rounded-md px-3 py-2 text-sm font-medium text-foreground/80 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
            >
              {link.label}
            </a>
          );
        }

        const panelId = `navbar-megamenu-${index}`;
        const isOpen = openMenu === index;
        return (
          <div key={index} className="relative">
            <button
              type="button"
              aria-expanded={isOpen}
              aria-controls={panelId}
              onClick={() => setOpenMenu(isOpen ? null : index)}
              onKeyDown={(event) => {
                if (event.key === "Escape") setOpenMenu(null);
              }}
              className="flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-foreground/80 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
            >
              {link.label}
              <ChevronDown className={cn("size-3.5 transition-transform", isOpen && "rotate-180")} aria-hidden="true" />
            </button>
            {isOpen ? (
              <div
                id={panelId}
                className="absolute left-1/2 top-full z-40 mt-2 w-screen max-w-md -translate-x-1/2 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-lg"
              >
                {link.megaMenu}
              </div>
            ) : null}
          </div>
        );
      })}
    </nav>
  );
}

export function NavbarActions({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
  return <div className={cn("flex shrink-0 items-center gap-2", className)} {...props} />;
}

export function NavbarMobileTrigger({ className, ...props }: React.ComponentPropsWithoutRef<"button">) {
  const { open, setOpen } = useNavbarMobileMenu("NavbarMobileTrigger");

  return (
    <button
      type="button"
      aria-expanded={open}
      aria-controls={DRAWER_ID}
      aria-label={open ? "Close menu" : "Open menu"}
      onClick={() => setOpen(!open)}
      className={cn(
        "flex size-9 shrink-0 items-center justify-center rounded-md text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring md:hidden",
        className,
      )}
      {...props}
    >
      {open ? <X className="size-5" aria-hidden="true" /> : <Menu className="size-5" aria-hidden="true" />}
    </button>
  );
}

export interface NavbarMobileDrawerProps extends React.ComponentPropsWithoutRef<"div"> {}

/**
 * Fixed-position overlay + panel — no portal, since fixed positioning
 * already escapes normal layout regardless of DOM depth. Traps focus while
 * open, restores focus to whatever was focused before opening (typically
 * NavbarMobileTrigger), closes on Escape or backdrop click, and locks
 * background scroll for the duration.
 */
export function NavbarMobileDrawer({ className, children, ...props }: NavbarMobileDrawerProps) {
  const { open, setOpen } = useNavbarMobileMenu("NavbarMobileDrawer");
  const panelRef = React.useRef<HTMLDivElement>(null);
  const previouslyFocused = React.useRef<HTMLElement | null>(null);

  // Must run before useFocusTrap's effect below: focus-trap moves focus
  // into the drawer as soon as it activates, so capturing "what was
  // focused before opening" has to happen first — effects run in
  // declaration order, so this one has to stay above the useFocusTrap call.
  React.useEffect(() => {
    if (open) {
      previouslyFocused.current = document.activeElement as HTMLElement;
      document.body.style.overflow = "hidden";
    } else {
      document.body.style.overflow = "";
      previouslyFocused.current?.focus();
    }
    return () => {
      document.body.style.overflow = "";
    };
  }, [open]);

  useFocusTrap(open, panelRef);

  React.useEffect(() => {
    if (!open) return;
    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") setOpen(false);
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [open, setOpen]);

  if (!open) return null;

  return (
    <div className="fixed inset-0 z-50 md:hidden">
      <div aria-hidden="true" className="absolute inset-0 bg-foreground/20" onClick={() => setOpen(false)} />
      <div
        ref={panelRef}
        id={DRAWER_ID}
        role="dialog"
        aria-modal="true"
        aria-label="Menu"
        className={cn(
          "absolute inset-y-0 right-0 flex w-full max-w-xs flex-col gap-1 overflow-y-auto bg-background p-4 shadow-lg",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </div>
  );
}