Sidebar
A vertical navigation panel that sits alongside main content, providing persistent access to top-level routes and sections in an application layout.
Search across all documentation pages
A vertical navigation panel that sits alongside main content, providing persistent access to top-level routes and sections in an application layout.
🤖 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>
);
}A minimal sidebar that renders a list of links. It uses h-screen to fill the viewport height and a dark background to visually separate it from the main content area.
"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>
);
}Uses usePathname() from Next.js to compare the current route against each item's href. The active item gets a distinct background and text color so the user always knows where they are.
"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>
);
}
// Usage
// <Sidebar items={[
// { label: "Dashboard", href: "/", icon: <HomeIcon /> },
// { label: "Settings", href: "/settings", icon: <GearIcon /> },
// ]} />Each item accepts an icon ReactNode rendered to the left of the label. The flex-shrink-0 on the icon wrapper prevents it from compressing when labels are long.
"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>
);
}Groups items under section headings using an uppercase label. The overflow-y-auto ensures the sidebar scrolls independently when there are many sections.
"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>
);
}The sidebar toggles between w-64 and w-16 widths. When collapsed, only icons are shown and a native title attribute provides a tooltip on hover. The chevron rotates to indicate state.
"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 (
<>
{/* Backdrop */}
<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"
/>
{/* Drawer */}
<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>
</>
);
}A slide-in drawer for mobile viewports. The backdrop overlay prevents interaction with the main content and closes the sidebar on click. Body scroll is locked while the drawer is open, and pressing Escape dismisses it.
"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>
{/* Navigation */}
<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>
{/* User profile */}
<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>
);
}A full-height sidebar with three zones: logo at the top, scrollable navigation in the middle, and a user profile pinned to the bottom. The truncate utility prevents long names or emails from breaking the layout.
"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), []);
// Close mobile drawer on route change
const pathname = usePathname();
useEffect(() => setMobileOpen(false), [pathname]);
// Lock body scroll when mobile drawer is open
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);
// Escape key closes mobile drawer
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>
{/* Sections */}
<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>
{/* Footer */}
{footer && (
<div className="border-t border-gray-800 px-4 py-4">
{footer}
</div>
)}
</>
);
return (
<>
{/* Desktop sidebar */}
<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>
{/* Mobile backdrop */}
<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"
/>
{/* Mobile drawer */}
<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>
</>
);
}
// --- Trigger for mobile ---
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>
);
}Key aspects:
SidebarProvider manages collapsed and mobile-open states through context, so the sidebar, trigger button, and layout can all coordinate without prop drilling.<nav> and a fixed mobile drawer coexist. Tailwind's lg:flex / lg:hidden toggles which one is visible, avoiding JavaScript-based breakpoint detection.pathname.startsWith(item.href + "/") to highlight parent items when a nested route is active, not just exact matches.title attribute gives hover context.useEffect on pathname closes the mobile drawer whenever the route changes, so the user sees the new page immediately.Not locking body scroll on mobile drawer - Without overflow: hidden on the body, users can scroll the page behind the open drawer. Always lock scroll when the drawer is open.
Using window.innerWidth instead of CSS breakpoints - JavaScript-based breakpoint checks cause hydration mismatches in SSR. Use Tailwind responsive classes (lg:flex, lg:hidden) instead.
Active state only matching exact paths - Using pathname === href misses child routes. Use startsWith for hierarchical navigation, but be careful with / matching everything.
Missing aria-label on the nav element - Screen readers need to distinguish between multiple <nav> elements on a page. Always label sidebars with aria-label="Main navigation" or similar.
Sidebar pushing content instead of overlaying on mobile - A fixed-width sidebar in the document flow will shrink the main content on small screens. Use position: fixed or a drawer pattern for mobile.
Forgetting to close the mobile drawer on link click - If the drawer stays open after navigation, users see stale content behind it. Close the drawer on route change or link click.
Z-index conflicts with modals or dropdowns - The sidebar and its backdrop need consistent z-index values that don't clash with other overlays. Establish a z-index scale in your project.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥