Navigation is the skeleton of every web application. Users interact with the navbar on every page, every session. A poorly built navbar — one that flickers, mis-positions on scroll, or breaks on mobile — undermines trust immediately. This guide covers four essential patterns: the fixed navbar with backdrop blur, animated mobile menu, auth-aware navigation, and dropdown menus with outside-click dismissal.
Fixed Navbar with Backdrop Blur
The modern glass-morphism navbar scrolls with the page but stays fixed, with a frosted glass background that activates once the user scrolls past a threshold. This requires listening to scroll events in a useEffect:
"use client";
import { useState, useEffect } from "react";
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
function handleScroll() {
setScrolled(window.scrollY > 16);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<header
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
zIndex: 50,
transition: `
background var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out),
backdrop-filter var(--duration-normal) var(--ease-out)
`,
background: scrolled
? "color-mix(in srgb, var(--color-background) 80%, transparent)"
: "transparent",
backdropFilter: scrolled ? "blur(12px)" : "none",
borderBottom: scrolled
? "1px solid var(--color-border)"
: "1px solid transparent",
}}
>
{/* Nav content */}
</header>
);
}
The { passive: true } option on the scroll event listener is a performance hint to the browser — it promises you won't call preventDefault(), enabling smoother scrolling on mobile.
color-mix(in srgb, ...) is a native CSS function that mixes the background token with transparency, so the blur effect shows through correctly. For browsers without color-mix support, a fallback of rgba(255,255,255,0.8) works fine.
Inner Layout
The navbar content uses a flex row with three zones: logo left, links center, actions right:
<nav
style={{
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
height: "4rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1.5rem",
}}
>
{/* Logo */}
<a href="/" style={{ display: "flex", alignItems: "center", gap: "0.5rem", textDecoration: "none" }}>
<span style={{ fontSize: "1.125rem", fontWeight: 700, color: "var(--color-foreground)" }}>
Acme
</span>
</a>
{/* Desktop links — hidden on mobile */}
<div className="hidden md:flex" style={{ gap: "0.25rem" }}>
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
style={{
padding: "0.5rem 0.75rem",
borderRadius: "var(--radius-md)",
fontSize: "0.875rem",
fontWeight: 500,
color: "var(--color-foreground-muted)",
textDecoration: "none",
transition: `color var(--duration-fast) var(--ease-out)`,
}}
>
{link.label}
</a>
))}
</div>
{/* CTA + Hamburger */}
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<a href="/signup" className="hidden md:inline-flex" style={{
padding: "0.5rem 1.25rem",
borderRadius: "var(--radius-full)",
background: "var(--color-accent)",
color: "var(--color-foreground)",
fontWeight: 600,
fontSize: "0.875rem",
textDecoration: "none",
}}>
Get started
</a>
{/* Hamburger — visible on mobile only */}
<HamburgerButton open={mobileOpen} onClick={() => setMobileOpen(!mobileOpen)} />
</div>
</nav>
Mobile Menu with AnimatePresence
The mobile menu slides down from the navbar using Framer Motion's AnimatePresence — this ensures the exit animation plays before the component is unmounted:
import { AnimatePresence, motion } from "framer-motion";
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];
// Inside Navbar, after <nav>:
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease }}
style={{ overflow: "hidden" }}
>
<div
style={{
padding: "1rem var(--container-padding-x) 1.5rem",
borderTop: "1px solid var(--color-border)",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
background: "var(--color-background)",
}}
>
{navLinks.map((link, i) => (
<motion.a
key={link.href}
href={link.href}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05, duration: 0.25, ease }}
onClick={() => setMobileOpen(false)}
style={{
padding: "0.75rem",
borderRadius: "var(--radius-md)",
fontSize: "0.9375rem",
fontWeight: 500,
color: "var(--color-foreground)",
textDecoration: "none",
}}
>
{link.label}
</motion.a>
))}
</div>
</motion.div>
)}
</AnimatePresence>
Each link staggers with a 50ms delay so the menu feels purposeful rather than mechanical.
Auth-Aware Navigation
A navbar that knows about authentication state requires careful handling of the loading state. Avoid layout shifts (CLS) by reserving space while auth status is unknown:
"use client";
import { useSession } from "next-auth/react";
function NavActions() {
const { data: session, status } = useSession();
// Loading state — render a skeleton to prevent layout shift
if (status === "loading") {
return (
<div style={{ display: "flex", gap: "0.75rem" }}>
<div
style={{
width: "80px",
height: "36px",
borderRadius: "var(--radius-full)",
background: "var(--color-border)",
animation: "pulse 1.5s ease-in-out infinite",
}}
/>
</div>
);
}
// Authenticated
if (session?.user) {
return (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<a href="/dashboard" style={{
padding: "0.5rem 1.25rem",
borderRadius: "var(--radius-full)",
background: "var(--color-accent)",
color: "var(--color-foreground)",
fontWeight: 600,
fontSize: "0.875rem",
textDecoration: "none",
}}>
Dashboard
</a>
<UserAvatar user={session.user} />
</div>
);
}
// Guest
return (
<div style={{ display: "flex", gap: "0.75rem" }}>
<a href="/login" style={{
padding: "0.5rem 1rem",
fontSize: "0.875rem",
fontWeight: 500,
color: "var(--color-foreground-muted)",
textDecoration: "none",
}}>
Sign in
</a>
<a href="/signup" style={{
padding: "0.5rem 1.25rem",
borderRadius: "var(--radius-full)",
background: "var(--color-accent)",
color: "var(--color-foreground)",
fontWeight: 600,
fontSize: "0.875rem",
textDecoration: "none",
}}>
Get started
</a>
</div>
);
}
The skeleton div has the same approximate dimensions as the actual buttons, so the navbar doesn't jump when auth resolves.
Dropdown Navigation with Outside-Click Dismissal
Dropdowns need to close when the user clicks outside. The cleanest pattern uses a ref on the container and a global mousedown listener:
import { useRef, useEffect, useState } from "react";
function NavDropdown({ label, items }: { label: string; items: NavItem[] }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={ref} style={{ position: "relative" }}>
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-haspopup="true"
style={{
display: "flex",
alignItems: "center",
gap: "4px",
padding: "0.5rem 0.75rem",
borderRadius: "var(--radius-md)",
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: 500,
color: "var(--color-foreground-muted)",
}}
>
{label}
<ChevronDown
size={14}
style={{
transition: `transform var(--duration-fast) var(--ease-out)`,
transform: open ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
style={{
position: "absolute",
top: "calc(100% + 8px)",
left: 0,
minWidth: "200px",
borderRadius: "var(--radius-lg)",
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
padding: "0.5rem",
boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
}}
>
{items.map((item) => (
<a key={item.href} href={item.href} style={{
display: "block",
padding: "0.625rem 0.75rem",
borderRadius: "var(--radius-md)",
fontSize: "0.875rem",
color: "var(--color-foreground)",
textDecoration: "none",
}}>
{item.label}
</a>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
The aria-expanded and aria-haspopup attributes make the dropdown accessible to keyboard and screen reader users. Add role="menu" to the dropdown container and role="menuitem" to each link for full ARIA compliance.
Performance Notes
A few things that commonly hurt navbar performance:
- Avoid re-renders on every scroll event. Use a threshold check (
scrollY > 16) so state only changes at the boundary, not continuously. - Use CSS transitions, not JS animations, for the scroll-activated blur. CSS transitions run on the compositor thread.
- Lazy load the mobile menu content. If the menu contains heavy components (mega-menus, search), render them only when
mobileOpenis true.
Pre-Built Navbar Components
If you need to ship fast, the Incubator navbar catalog includes 18+ production-ready variants: glassmorphism navbars, animated underline indicators, morphing pills, mega-menu layouts, sidebar-toggle navbars, and more — all built on the same token system and ready to copy into your Next.js project.