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
mobileOpensea 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
- Componentes navbar en React: secciones de navegación para copiar y pegar de cualquier estilo.
- Explorador de efectos hover: patrones interactivos de hover para enlaces y botones de navegación.
- Explorador de animaciones de scroll: comportamientos del navbar al hacer scroll y efectos de entrada.
- Incubator frente a Aceternity UI: bibliotecas de componentes animados comparadas.
- Catálogo completo de componentes: las 449 secciones navegables por tipo y estilo.