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.jsonnpm dependencies: lucide-react
Props
NavbarProps
| Prop | Type | Required |
|---|---|---|
| scrollThreshold | number | No |
| sticky | boolean | No |
NavbarLinksProps
| Prop | Type | Required |
|---|---|---|
| links | NavbarLink[] | 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>
);
}