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.
Busca en todas las páginas de la documentación
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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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.
"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:
SidebarProvider gestiona 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.<nav> de escritorio oculto y un cajón móvil fijo coexisten. Las clases responsivas de Tailwind lg:flex / lg:hidden alternan cuál es visible, evitando la detección de punto de quiebre basada en JavaScript.pathname.startsWith(item.href + "/") para resaltar elementos padre cuando una ruta anidada está activa, no solo coincidencias exactas.title nativo proporciona contexto al pasar el cursor.useEffect en pathname cierra el cajón móvil siempre que la ruta cambie, de modo que el usuario vea la nueva página inmediatamente.No bloquear desplazamiento del cuerpo en cajón móvil - Sin overflow: hidden en 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.innerWidth en 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 === href omite rutas secundarias. Utiliza startsWith para navegación jerárquica, pero ten cuidado con / que coincide con todo.
Falta de aria-label en el elemento nav - Los lectores de pantalla necesitan distinguir entre múltiples elementos <nav> en una página. Siempre etiqueta barras laterales con aria-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: fixed o 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.
Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥