Sidebar
A vertical navigation panel that sits alongside main content, providing persistent access to top-level routes and sections in an application layout.
Use Cases
- Primary app navigation in dashboard layouts
- Settings page with grouped menu sections
- Documentation site with nested page links
- Admin panels with role-based menu items
- E-commerce category browsing
- Multi-step wizard with step indicators
- File explorer or folder tree navigation
Simplest Implementation
"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.
Variations
With Active State Highlighting
"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.
With Icons
"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.
With Sections and Groups
"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.
Collapsible Sidebar
"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.
Mobile Drawer Sidebar
"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.
With User Profile and 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>
{/* 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.
Complex Implementation
"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:
- Context-driven state —
SidebarProvidermanages collapsed and mobile-open states through context, so the sidebar, trigger button, and layout can all coordinate without prop drilling. - Responsive dual rendering — a hidden desktop
<nav>and a fixed mobile drawer coexist. Tailwind'slg:flex/lg:hiddentoggles which one is visible, avoiding JavaScript-based breakpoint detection. - Route-aware active state — uses
pathname.startsWith(item.href + "/")to highlight parent items when a nested route is active, not just exact matches. - Collapsible with icon tooltips — when collapsed, section titles hide, labels disappear, and items center around their icons. The native
titleattribute gives hover context. - Badge support — optional badge strings (e.g., notification counts) render as pills next to labels and automatically hide in collapsed mode.
- Automatic mobile close on navigation — the
useEffectonpathnamecloses the mobile drawer whenever the route changes, so the user sees the new page immediately. - Keyboard dismissal — the Escape key closes the mobile drawer, matching standard drawer accessibility patterns.
Gotchas
-
Not locking body scroll on mobile drawer — Without
overflow: hiddenon the body, users can scroll the page behind the open drawer. Always lock scroll when the drawer is open. -
Using
window.innerWidthinstead 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 === hrefmisses child routes. UsestartsWithfor hierarchical navigation, but be careful with/matching everything. -
Missing
aria-labelon the nav element — Screen readers need to distinguish between multiple<nav>elements on a page. Always label sidebars witharia-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: fixedor 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.
Related
- Button — Trigger buttons for sidebar toggle and mobile menu
- Modal — Overlay patterns similar to mobile drawer
- Breadcrumb — Complementary navigation showing current location