Volver al blog

Componentes navbar en React: patrones y ejemplos para copiar y pegar

Publicado el 14 de marzo de 2026·7 min read

La navegación es el esqueleto de toda aplicación web. Los usuarios interactúan con el navbar en cada página y en cada sesión. Un navbar mal construido, uno que parpadea, se descoloca al hacer scroll o se rompe en móvil, destruye la confianza al instante. Esta guía cubre cuatro patrones esenciales: el navbar fijo con backdrop blur, el menú móvil animado, la navegación según la sesión y los menús dropdown que se cierran al hacer clic fuera.

Navbar fijo con backdrop blur

El navbar moderno tipo glass-morphism hace scroll con la página pero permanece fijo, con un fondo de vidrio esmerilado que se activa cuando el usuario pasa cierto umbral. Para lograrlo hay que escuchar los eventos de scroll dentro de 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>
  );
}

La opción { passive: true } del listener de scroll es una pista de rendimiento para el navegador: le promete que no vas a llamar a preventDefault(), lo que permite un scroll más fluido en móvil.

color-mix(in srgb, ...) es una función nativa de CSS que mezcla el token de fondo con transparencia, de modo que el efecto blur se vea a través correctamente. En navegadores sin soporte para color-mix, un fallback de rgba(255,255,255,0.8) funciona perfectamente.

Layout interno

El contenido del navbar usa una fila flex con tres zonas: logo a la izquierda, enlaces al centro y acciones a la derecha:

<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>

Menú móvil con AnimatePresence

El menú móvil se despliega desde el navbar usando el AnimatePresence de Framer Motion, lo que garantiza que la animación de salida se reproduzca antes de que el componente se desmonte:

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>

Cada enlace aparece de forma escalonada con un retardo de 50 ms, así el menú se siente intencional en lugar de mecánico.

Navegación según la sesión

Un navbar que conoce el estado de autenticación exige manejar con cuidado el estado de carga. Evita los saltos de layout (CLS) reservando el espacio mientras se desconoce el estado de la sesión:

"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>
  );
}

El div del skeleton tiene aproximadamente las mismas dimensiones que los botones reales, así el navbar no da saltos cuando se resuelve la sesión.

Navegación con dropdown que se cierra al hacer clic fuera

Los dropdowns deben cerrarse cuando el usuario hace clic fuera. El patrón más limpio usa una ref en el contenedor y un listener global de 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>
  );
}

Los atributos aria-expanded y aria-haspopup hacen que el dropdown sea accesible para quienes usan teclado o lector de pantalla. Añade role="menu" al contenedor del dropdown y role="menuitem" a cada enlace para una conformidad ARIA completa.

Notas de rendimiento

Algunas cosas que suelen perjudicar el rendimiento del navbar:

  • Evita los re-renders en cada evento de scroll. Usa una comprobación de umbral (scrollY > 16) para que el estado solo cambie en la frontera, no de forma continua.
  • Usa transiciones CSS, no animaciones en JS, para el blur activado por scroll. Las transiciones CSS se ejecutan en el hilo del compositor.
  • Carga el contenido del menú móvil de forma diferida. Si el menú contiene componentes pesados (mega-menús, buscador), renderízalos solo cuando mobileOpen sea true.

Componentes navbar listos para usar

Si necesitas lanzar rápido, el catálogo de navbars de Incubator incluye más de 18 variantes listas para producción: navbars glassmorphism, indicadores de subrayado animado, píldoras que se transforman, layouts con mega-menú, navbars con sidebar conmutable y mucho más, todos construidos sobre el mismo sistema de tokens y listos para copiar en tu proyecto Next.js.

Relacionado en incubator

VA

Victor Aubague

Desarrollador y creador de Incubator

Desarrollador full-stack especializado en React, Next.js y TypeScript. Creé Incubator para ayudar a los desarrolladores a lanzar interfaces bonitas más rápido, todos los componentes provienen de proyectos reales de clientes y de código en producción.

LinkedIn

Related articles

Componentes navbar en React: patrones y ejemplos para copiar