Barra lateral
Un panel de navegación vertical que se coloca junto al contenido principal, proporcionando acceso persistente a rutas de nivel superior y secciones en un diseño de aplicación.
Casos de uso
- Navegación de aplicación principal en diseños de panel de control
- Página de configuración con secciones de menú agrupadas
- Sitio de documentación con enlaces de página anidados
- Paneles de administración con elementos de menú basados en roles
- Exploración de categorías de comercio electrónico
- Asistente de varios pasos con indicadores de paso
- Explorador de archivos o navegación de árbol de carpetas
Implementación más simple
"use client";
interface SidebarProps {
items: { label: string; href: string }[];
}
export function Sidebar({ items }: SidebarProps) {
return (
<nav className="flex h-screen w-64 flex-col bg-gray-900 p-4">
<ul className="space-y-1">
{items.map((item) => (
<li key={item.href}>
<a
href={item.href}
className="block rounded-lg px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-800 hover:text-white"
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
);
}Una barra lateral mínima que renderiza una lista de enlaces. Utiliza h-screen para llenar la altura de la ventana y un fondo oscuro para separar visualmente del área de contenido principal.
Variaciones
Con resaltado de estado activo
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface SidebarItem {
label: string;
href: string;
}
interface SidebarProps {
items: SidebarItem[];
}
export function Sidebar({ items }: SidebarProps) {
const pathname = usePathname();
return (
<nav className="flex h-screen w-64 flex-col bg-gray-900 p-4">
<ul className="space-y-1">
{items.map((item) => {
const active = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-gray-800 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
}`}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}Utiliza usePathname() de Next.js para comparar la ruta actual con el href de cada elemento. El elemento activo obtiene un fondo y color de texto distintos para que el usuario siempre sepa dónde se encuentra.
Con iconos
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface SidebarItem {
label: string;
href: string;
icon: React.ReactNode;
}
interface SidebarProps {
items: SidebarItem[];
}
export function Sidebar({ items }: SidebarProps) {
const pathname = usePathname();
return (
<nav className="flex h-screen w-64 flex-col bg-gray-900 p-4">
<ul className="space-y-1">
{items.map((item) => {
const active = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-gray-800 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
}`}
>
<span className="h-5 w-5 flex-shrink-0">{item.icon}</span>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
// Uso
// <Sidebar items={[
// { label: "Panel de control", href: "/", icon: <HomeIcon /> },
// { label: "Configuración", href: "/settings", icon: <GearIcon /> },
// ]} />Cada elemento acepta un icon ReactNode renderizado a la izquierda de la etiqueta. El flex-shrink-0 en el envoltorio del icono evita que se comprima cuando las etiquetas son largas.
Con secciones y grupos
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface SidebarItem {
label: string;
href: string;
}
interface SidebarSection {
title: string;
items: SidebarItem[];
}
interface SidebarProps {
sections: SidebarSection[];
}
export function Sidebar({ sections }: SidebarProps) {
const pathname = usePathname();
return (
<nav className="flex h-screen w-64 flex-col overflow-y-auto bg-gray-900 p-4">
{sections.map((section) => (
<div key={section.title} className="mb-6">
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
{section.title}
</h3>
<ul className="space-y-1">
{section.items.map((item) => {
const active = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-gray-800 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
}`}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}Agrupa elementos bajo encabezados de sección usando una etiqueta en mayúsculas. El overflow-y-auto garantiza que la barra lateral se desplace independientemente cuando hay muchas secciones.
Barra lateral contraíble
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface SidebarItem {
label: string;
href: string;
icon: React.ReactNode;
}
interface SidebarProps {
items: SidebarItem[];
}
export function CollapsibleSidebar({ items }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
const pathname = usePathname();
return (
<nav
className={`flex h-screen flex-col bg-gray-900 p-4 transition-all duration-200 ${
collapsed ? "w-16" : "w-64"
}`}
>
<button
onClick={() => setCollapsed((prev) => !prev)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="mb-4 self-end rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white"
>
<svg
className={`h-5 w-5 transition-transform ${collapsed ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<ul className="space-y-1">
{items.map((item) => {
const active = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
title={collapsed ? item.label : undefined}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-gray-800 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
}`}
>
<span className="h-5 w-5 flex-shrink-0">{item.icon}</span>
{!collapsed && <span>{item.label}</span>}
</Link>
</li>
);
})}
</ul>
</nav>
);
}La barra lateral alterna entre anchos de w-64 y w-16. Cuando está contraída, solo se muestran los iconos y un atributo title nativo proporciona un tooltip al pasar el cursor. El chevron rota para indicar el estado.
Barra lateral del cajón móvil
"use client";
import { useEffect, useRef } from "react";
import Link from "next/link";
interface SidebarItem {
label: string;
href: string;
}
interface MobileSidebarProps {
items: SidebarItem[];
open: boolean;
onClose: () => void;
}
export function MobileSidebar({ items, open, onClose }: MobileSidebarProps) {
const navRef = useRef<HTMLElement>(null);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
if (open) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [open, onClose]);
return (
<>
{/* Telón de fondo */}
<div
className={`fixed inset-0 z-40 bg-black/50 transition-opacity ${
open ? "opacity-100" : "pointer-events-none opacity-0"
}`}
onClick={onClose}
aria-hidden="true"
/>
{/* Cajón */}
<nav
ref={navRef}
className={`fixed inset-y-0 left-0 z-50 w-72 bg-gray-900 p-4 transition-transform duration-200 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
aria-label="Mobile navigation"
>
<button
onClick={onClose}
aria-label="Close menu"
className="mb-4 rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<ul className="space-y-1">
{items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={onClose}
className="block rounded-lg px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-800 hover:text-white"
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
</>
);
}Un cajón deslizable para ventanas móviles. La superposición del telón de fondo previene la interacción con el contenido principal y cierra la barra lateral al hacer clic. El desplazamiento del cuerpo se bloquea mientras el cajón está abierto, y presionar Escape lo cierra.
Con perfil de usuario y logo
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface SidebarItem {
label: string;
href: string;
icon: React.ReactNode;
}
interface SidebarProps {
items: SidebarItem[];
user: { name: string; email: string; avatarUrl?: string };
logo: React.ReactNode;
}
export function Sidebar({ items, user, logo }: SidebarProps) {
const pathname = usePathname();
return (
<nav className="flex h-screen w-64 flex-col bg-gray-900">
{/* Logo */}
<div className="flex h-16 items-center px-6">
{logo}
</div>
{/* Navegación */}
<ul className="flex-1 space-y-1 overflow-y-auto px-4">
{items.map((item) => {
const active = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-gray-800 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
}`}
>
<span className="h-5 w-5 flex-shrink-0">{item.icon}</span>
{item.label}
</Link>
</li>
);
})}
</ul>
{/* Perfil de usuario */}
<div className="border-t border-gray-800 px-4 py-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-700 text-sm font-medium text-white">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
) : (
user.name.charAt(0).toUpperCase()
)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{user.name}</p>
<p className="truncate text-xs text-gray-400">{user.email}</p>
</div>
</div>
</div>
</nav>
);
}Una barra lateral de altura completa con tres zonas: logo en la parte superior, navegación desplazable en el medio y un perfil de usuario fijado en la parte inferior. La utilidad truncate evita que nombres o correos electrónicos largos rompan el diseño.
Implementación compleja
"use client";
import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
// --- Context ---
interface SidebarContextValue {
collapsed: boolean;
toggle: () => void;
mobileOpen: boolean;
setMobileOpen: (open: boolean) => void;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);
function useSidebar() {
const ctx = useContext(SidebarContext);
if (!ctx) throw new Error("useSidebar must be used inside SidebarProvider");
return ctx;
}
// --- Provider ---
interface SidebarProviderProps {
children: React.ReactNode;
defaultCollapsed?: boolean;
}
export function SidebarProvider({ children, defaultCollapsed = false }: SidebarProviderProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const [mobileOpen, setMobileOpen] = useState(false);
const toggle = useCallback(() => setCollapsed((prev) => !prev), []);
// Cerrar el cajón móvil al cambiar de ruta
const pathname = usePathname();
useEffect(() => setMobileOpen(false), [pathname]);
// Bloquear desplazamiento del cuerpo cuando el cajón móvil está abierto
useEffect(() => {
document.body.style.overflow = mobileOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
return (
<SidebarContext.Provider value={{ collapsed, toggle, mobileOpen, setMobileOpen }}>
{children}
</SidebarContext.Provider>
);
}
// --- Sidebar ---
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
badge?: string;
}
interface NavSection {
title?: string;
items: NavItem[];
}
interface SidebarProps {
sections: NavSection[];
logo: React.ReactNode;
logoCollapsed?: React.ReactNode;
footer?: React.ReactNode;
}
export function Sidebar({ sections, logo, logoCollapsed, footer }: SidebarProps) {
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebar();
const pathname = usePathname();
const navRef = useRef<HTMLElement>(null);
// Tecla Escape cierra el cajón móvil
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setMobileOpen(false);
}
if (mobileOpen) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [mobileOpen, setMobileOpen]);
const navContent = (
<>
{/* Logo */}
<div className="flex h-16 items-center justify-between px-4">
<div className="flex items-center">
{collapsed && logoCollapsed ? logoCollapsed : logo}
</div>
<button
onClick={toggle}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="hidden rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white lg:block"
>
<svg
className={`h-5 w-5 transition-transform duration-200 ${collapsed ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
{/* Secciones */}
<div className="flex-1 space-y-6 overflow-y-auto px-3 py-4">
{sections.map((section, idx) => (
<div key={section.title ?? idx}>
{section.title && !collapsed && (
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
{section.title}
</h3>
)}
<ul className="space-y-1">
{section.items.map((item) => {
const active = pathname === item.href || pathname.startsWith(item.href + "/");
return (
<li key={item.href}>
<Link
href={item.href}
title={collapsed ? item.label : undefined}
className={`group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-gray-800 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
} ${collapsed ? "justify-center" : ""}`}
>
<span className="h-5 w-5 flex-shrink-0">{item.icon}</span>
{!collapsed && (
<>
<span className="flex-1">{item.label}</span>
{item.badge && (
<span className="rounded-full bg-blue-600 px-2 py-0.5 text-xs font-medium text-white">
{item.badge}
</span>
)}
</>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
</div>
{/* Pie de página */}
{footer && (
<div className="border-t border-gray-800 px-4 py-4">
{footer}
</div>
)}
</>
);
return (
<>
{/* Barra lateral de escritorio */}
<nav
ref={navRef}
className={`hidden h-screen flex-col bg-gray-900 transition-all duration-200 lg:flex ${
collapsed ? "w-16" : "w-64"
}`}
aria-label="Main navigation"
>
{navContent}
</nav>
{/* Telón de fondo móvil */}
<div
className={`fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden ${
mobileOpen ? "opacity-100" : "pointer-events-none opacity-0"
}`}
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
{/* Cajón móvil */}
<nav
className={`fixed inset-y-0 left-0 z-50 flex w-72 flex-col bg-gray-900 transition-transform duration-200 lg:hidden ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`}
aria-label="Mobile navigation"
>
{navContent}
</nav>
</>
);
}
// --- Activador para móvil ---
export function SidebarTrigger() {
const { setMobileOpen } = useSidebar();
return (
<button
onClick={() => setMobileOpen(true)}
aria-label="Open menu"
className="rounded-lg p-2 text-gray-600 hover:bg-gray-100 lg:hidden"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
);
}Aspectos clave:
- Estado impulsado por contexto —
SidebarProvidergestiona el estado contraído y móvil abierto a través del contexto, por lo que la barra lateral, el botón de activación y el diseño pueden coordinarse todos sin prop drilling. - Renderizado dual responsivo — un
<nav>de escritorio oculto y un cajón móvil fijo coexisten. Las clases responsivas de Tailwindlg:flex/lg:hiddenalternan cuál es visible, evitando la detección de punto de quiebre basada en JavaScript. - Estado activo consciente de ruta — utiliza
pathname.startsWith(item.href + "/")para resaltar elementos padre cuando una ruta anidada está activa, no solo coincidencias exactas. - Contraíble con tooltips de icono — cuando está contraído, los títulos de sección se ocultan, las etiquetas desaparecen y los elementos se centran alrededor de sus iconos. El atributo
titlenativo proporciona contexto al pasar el cursor. - Soporte de distintivo — las cadenas de distintivo opcionales (por ejemplo, recuentos de notificaciones) se renderan como píldoras junto a las etiquetas y se ocultan automáticamente en modo contraído.
- Cierre automático de móvil en navegación — el
useEffectenpathnamecierra el cajón móvil siempre que la ruta cambie, de modo que el usuario vea la nueva página inmediatamente. - Cierre de teclado — la tecla Escape cierra el cajón móvil, coincidiendo con patrones de accesibilidad estándar del cajón.
Trampas
-
No bloquear desplazamiento del cuerpo en cajón móvil — Sin
overflow: hiddenen el cuerpo, los usuarios pueden desplazarse por la página detrás del cajón abierto. Siempre bloquea el desplazamiento cuando el cajón está abierto. -
Usar
window.innerWidthen lugar de puntos de quiebre CSS — Los controles de punto de quiebre basados en JavaScript causan desincronizaciones de hidratación en SSR. Utiliza clases responsivas de Tailwind (lg:flex,lg:hidden) en su lugar. -
Estado activo coincidiendo solo rutas exactas — Usar
pathname === hrefomite rutas secundarias. UtilizastartsWithpara navegación jerárquica, pero ten cuidado con/que coincide con todo. -
Falta de
aria-labelen el elemento nav — Los lectores de pantalla necesitan distinguir entre múltiples elementos<nav>en una página. Siempre etiqueta barras laterales conaria-label="Main navigation"o similar. -
Barra lateral empujando contenido en lugar de superponer en móvil — Una barra lateral de ancho fijo en el flujo del documento encogará el contenido principal en pantallas pequeñas. Utiliza
position: fixedo un patrón de cajón para móvil. -
Olvidar cerrar el cajón móvil al hacer clic en enlace — Si el cajón se mantiene abierto después de la navegación, los usuarios ven contenido obsoleto detrás de él. Cierra el cajón al cambiar de ruta o al hacer clic en enlace.
-
Conflictos de índice z con modales o menús desplegables — La barra lateral y su telón de fondo necesitan valores de índice z coherentes que no choquen con otras superposiciones. Establece una escala de índice z en tu proyecto.
Relacionado
- Botón — Botones de activación para alternancia de barra lateral y menú móvil
- Modal — Patrones de superposición similares a cajón móvil
- Migas de pan — Navegación complementaria que muestra la ubicación actual