A collapsible sidebar is the standard navigation pattern for dashboards and admin panels. Here is how to build one.
Step 1: Navigation Data Structure
// lib/navigation.ts
import {
LayoutDashboard,
Users,
Package,
FileText,
Settings,
BarChart,
Mail,
HelpCircle,
type LucideIcon,
} from "lucide-react";
export interface NavItem {
label: string;
href: string;
icon: LucideIcon;
badge?: string;
children?: NavItem[];
}
export const navigation: NavItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{
label: "Users",
href: "/users",
icon: Users,
badge: "12",
children: [
{ label: "All Users", href: "/users", icon: Users },
{ label: "Roles", href: "/users/roles", icon: Users },
{ label: "Permissions", href: "/users/permissions", icon: Users },
],
},
{ label: "Products", href: "/products", icon: Package },
{
label: "Content",
href: "/content",
icon: FileText,
children: [
{ label: "Blog Posts", href: "/content/posts", icon: FileText },
{ label: "Pages", href: "/content/pages", icon: FileText },
{ label: "Media", href: "/content/media", icon: FileText },
],
},
{ label: "Analytics", href: "/analytics", icon: BarChart },
{ label: "Messages", href: "/messages", icon: Mail, badge: "3" },
{ label: "Settings", href: "/settings", icon: Settings },
{ label: "Help", href: "/help", icon: HelpCircle },
];
Step 2: Sidebar Component
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
PanelLeftClose,
PanelLeft,
} from "lucide-react";
import { navigation, type NavItem } from "@/lib/navigation";
export function Sidebar() {
const [isCollapsed, setIsCollapsed] = useState(false);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const pathname = usePathname();
function toggleExpand(label: string) {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}
return (
<aside
className={`flex h-screen flex-col border-r bg-white transition-all dark:border-gray-800 dark:bg-gray-900 ${
isCollapsed ? "w-16" : "w-64"
}`}
>
{/* Header */}
<div className="flex h-14 items-center justify-between border-b px-3 dark:border-gray-800">
{!isCollapsed && (
<span className="text-lg font-bold">AppName</span>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
>
{isCollapsed ? (
<PanelLeft className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
</button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto p-2">
<ul className="space-y-1">
{navigation.map((item) => (
<SidebarItem
key={item.label}
item={item}
isCollapsed={isCollapsed}
isActive={pathname === item.href || pathname.startsWith(item.href + "/")}
isExpanded={expandedItems.has(item.label)}
onToggle={() => toggleExpand(item.label)}
pathname={pathname}
/>
))}
</ul>
</nav>
{/* Footer */}
{!isCollapsed && (
<div className="border-t p-3 dark:border-gray-800">
<div className="flex items-center gap-3 rounded-lg p-2">
<div className="h-8 w-8 rounded-full bg-blue-100 dark:bg-blue-900" />
<div className="flex-1">
<p className="text-sm font-medium">John Doe</p>
<p className="text-xs text-gray-500">john@example.com</p>
</div>
</div>
</div>
)}
</aside>
);
}
function SidebarItem({
item,
isCollapsed,
isActive,
isExpanded,
onToggle,
pathname,
}: {
item: NavItem;
isCollapsed: boolean;
isActive: boolean;
isExpanded: boolean;
onToggle: () => void;
pathname: string;
}) {
const hasChildren = item.children && item.children.length > 0;
const Icon = item.icon;
if (isCollapsed) {
return (
<li>
<Link
href={item.href}
className={`flex items-center justify-center rounded-lg p-2 transition-colors ${
isActive
? "bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
}`}
title={item.label}
>
<Icon className="h-5 w-5" />
{item.badge && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] text-white">
{item.badge}
</span>
)}
</Link>
</li>
);
}
return (
<li>
<div className="flex items-center">
<Link
href={item.href}
className={`flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${
isActive && !hasChildren
? "bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{item.label}</span>
{item.badge && (
<span className="rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900 dark:text-red-300">
{item.badge}
</span>
)}
</Link>
{hasChildren && (
<button
onClick={onToggle}
className="rounded p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${
isExpanded ? "rotate-180" : ""
}`}
/>
</button>
)}
</div>
{hasChildren && isExpanded && (
<ul className="ml-4 mt-1 space-y-1 border-l pl-3 dark:border-gray-700">
{item.children!.map((child) => (
<li key={child.href}>
<Link
href={child.href}
className={`block rounded-lg px-3 py-1.5 text-sm transition-colors ${
pathname === child.href
? "text-blue-700 dark:text-blue-300"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
{child.label}
</Link>
</li>
))}
</ul>
)}
</li>
);
}
Step 3: Mobile Sidebar Drawer
"use client";
import { useState, useEffect } from "react";
import { Menu, X } from "lucide-react";
import { usePathname } from "next/navigation";
export function MobileSidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
// Close on navigation
useEffect(() => {
setIsOpen(false);
}, [pathname]);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="rounded-lg p-2 lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setIsOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-64 lg:hidden">
<div className="absolute right-2 top-2">
<button
onClick={() => setIsOpen(false)}
className="rounded-lg p-1 text-gray-500 hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
</div>
{children}
</div>
</>
)}
</>
);
}
Need a Dashboard UI?
We build dashboards with intuitive navigation, responsive design, and polished user experiences. Contact us to discuss your project.