Retour au blog

Composants navbar React : patterns et exemples à copier-coller

Publié le 14 mars 2026·7 min read

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 mobileOpen est 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.

VA

Victor Aubague

Développeur & créateur d'Incubator

Développeur full-stack spécialisé en React, Next.js et TypeScript. J'ai créé Incubator pour aider les développeurs à livrer de belles interfaces plus rapidement — tous les composants sont issus de vrais projets clients et de code en production.

LinkedIn
Composants navbar React : patterns et exemples à copier-coller — Incubator