La navigation est le squelette de toute application web. Les utilisateurs interagissent avec la navbar sur chaque page, à chaque session. Une navbar mal construite — qui scintille, se repositionne mal au scroll, ou casse sur mobile — entame immédiatement la confiance. Ce guide couvre quatre patterns essentiels : la navbar fixe avec backdrop blur, le menu mobile animé, la navigation consciente de l'authentification, et les menus déroulants avec fermeture au clic extérieur.
Navbar fixe avec backdrop blur
La navbar moderne style glass-morphism reste fixe en défilant, avec un fond verre dépoli qui s'active dès que l'utilisateur passe un certain seuil de scroll. Cela nécessite d'écouter les événements de scroll dans un 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>
);
}
L'option { passive: true } sur l'écouteur d'événement scroll est un signal de performance pour le navigateur — il indique que vous n'appellerez pas preventDefault(), ce qui permet un défilement plus fluide sur mobile.
color-mix(in srgb, ...) est une fonction CSS native qui mélange le token de fond avec de la transparence, pour que l'effet de blur s'affiche correctement. Pour les navigateurs sans support de color-mix, un fallback de rgba(255,255,255,0.8) convient.
Layout interne
Le contenu de la navbar utilise une rangée flex avec trois zones : logo à gauche, liens au centre, actions à droite :
<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>
Menu mobile avec AnimatePresence
Le menu mobile glisse depuis la navbar grâce à AnimatePresence de Framer Motion — cela garantit que l'animation de sortie se joue avant que le composant soit démonté :
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>
Chaque lien décale avec un délai de 50 ms pour que le menu paraisse intentionnel plutôt que mécanique.
Navigation consciente de l'authentification
Une navbar qui connaît l'état d'authentification nécessite une gestion soignée de l'état de chargement. Évitez les décalages de layout (CLS) en réservant de l'espace pendant que le statut d'auth est inconnu :
"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>
);
}
La div squelette a approximativement les mêmes dimensions que les vrais boutons, donc la navbar ne saute pas quand l'auth se résout.
Navigation avec dropdown et fermeture au clic extérieur
Les dropdowns doivent se fermer quand l'utilisateur clique ailleurs. Le pattern le plus propre utilise une ref sur le conteneur et un écouteur global mousedown :
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>
);
}
Les attributs aria-expanded et aria-haspopup rendent le dropdown accessible au clavier et aux lecteurs d'écran. Ajoutez role="menu" au conteneur du dropdown et role="menuitem" à chaque lien pour une conformité ARIA complète.
Notes de performance
Quelques points qui nuisent souvent aux performances des navbars :
- Évitez les re-renders à chaque événement scroll. Utilisez un test de seuil (
scrollY > 16) pour que l'état ne change qu'à la limite, pas en continu. - Utilisez des transitions CSS, pas des animations JS, pour le blur activé au scroll. Les transitions CSS s'exécutent sur le thread du compositeur.
- Chargez le contenu du menu mobile en lazy. Si le menu contient des composants lourds (mega-menus, recherche), rendez-les seulement quand
mobileOpenest vrai.
Composants navbar prêts à l'emploi
Si vous devez livrer rapidement, le catalogue navbar d'Incubator propose plus de 18 variantes production-ready : navbars glassmorphism, indicateurs avec soulignement animé, pills morphing, layouts mega-menu, navbars avec toggle sidebar, et bien plus — tous construits sur le même système de tokens et prêts à copier dans votre projet Next.js.