Collapsible Sidebar
App sidebar that animates between full and icon-only widths.
components/ui/collapsible-sidebar.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { BarChart3, ChevronsLeft, FolderKanban, Home, Inbox, Settings, Users, type LucideIcon } from "lucide-react";
import { useState } from "react";
type Item = { id: string; label: string; icon: LucideIcon; badge?: number };
const defaultItems: Item[] = [
{ id: "home", label: "Home", icon: Home },
{ id: "inbox", label: "Inbox", icon: Inbox, badge: 4 },
{ id: "projects", label: "Projects", icon: FolderKanban },
{ id: "analytics", label: "Analytics", icon: BarChart3 },
{ id: "team", label: "Team", icon: Users },
{ id: "settings", label: "Settings", icon: Settings },
];
export function CollapsibleSidebar({ items = defaultItems }: { items?: Item[] }) {
const [collapsed, setCollapsed] = useState(false);
const [active, setActive] = useState(items[0].id);
return (
<motion.nav
aria-label="Main"
animate={{ width: collapsed ? 68 : 232 }}
transition={{ type: "spring", stiffness: 300, damping: 32 }}
className="flex h-[420px] flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white p-3"
>
<div className="mb-4 flex items-center justify-between px-1.5">
<AnimatePresence initial={false}>
{!collapsed && (
<motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="whitespace-nowrap font-semibold text-zinc-900">
Workspace
</motion.span>
)}
</AnimatePresence>
<button
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-expanded={!collapsed}
className="grid size-8 shrink-0 place-items-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
>
<motion.span animate={{ rotate: collapsed ? 180 : 0 }}>
<ChevronsLeft className="size-4" />
</motion.span>
</button>
</div>
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id}>
<button
onClick={() => setActive(item.id)}
title={collapsed ? item.label : undefined}
aria-current={active === item.id ? "page" : undefined}
className={`relative flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-colors ${active === item.id ? "text-white" : "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"}`}
>
{active === item.id && <motion.span layoutId="sidebar-active" className="absolute inset-0 rounded-xl bg-zinc-900" transition={{ type: "spring", stiffness: 420, damping: 34 }} />}
<item.icon className="relative size-[18px] shrink-0" />
<AnimatePresence initial={false}>
{!collapsed && (
<motion.span initial={{ opacity: 0, x: -6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -6 }} className="relative flex-1 whitespace-nowrap text-left">
{item.label}
</motion.span>
)}
</AnimatePresence>
{item.badge && (
<span className={`relative grid min-w-5 place-items-center rounded-full px-1.5 text-[10px] font-semibold ${collapsed ? "absolute right-1.5 top-1.5 min-w-4 px-1" : ""} ${active === item.id ? "bg-white text-zinc-900" : "bg-blue-600 text-white"}`}>
{item.badge}
</span>
)}
</button>
</li>
))}
</ul>
</motion.nav>
);
}
export default function Demo() {
return <CollapsibleSidebar />;
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/collapsible-sidebar.tsxcomponents/ui/collapsible-sidebar.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { BarChart3, ChevronsLeft, FolderKanban, Home, Inbox, Settings, Users, type LucideIcon } from "lucide-react"; import { useState } from "react"; type Item = { id: string; label: string; icon: LucideIcon; badge?: number }; const defaultItems: Item[] = [ { id: "home", label: "Home", icon: Home }, { id: "inbox", label: "Inbox", icon: Inbox, badge: 4 }, { id: "projects", label: "Projects", icon: FolderKanban }, { id: "analytics", label: "Analytics", icon: BarChart3 }, { id: "team", label: "Team", icon: Users }, { id: "settings", label: "Settings", icon: Settings }, ]; export function CollapsibleSidebar({ items = defaultItems }: { items?: Item[] }) { const [collapsed, setCollapsed] = useState(false); const [active, setActive] = useState(items[0].id); return ( <motion.nav aria-label="Main" animate={{ width: collapsed ? 68 : 232 }} transition={{ type: "spring", stiffness: 300, damping: 32 }} className="flex h-[420px] flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white p-3" > <div className="mb-4 flex items-center justify-between px-1.5"> <AnimatePresence initial={false}> {!collapsed && ( <motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="whitespace-nowrap font-semibold text-zinc-900"> Workspace </motion.span> )} </AnimatePresence> <button onClick={() => setCollapsed((c) => !c)} aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} aria-expanded={!collapsed} className="grid size-8 shrink-0 place-items-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" > <motion.span animate={{ rotate: collapsed ? 180 : 0 }}> <ChevronsLeft className="size-4" /> </motion.span> </button> </div> <ul className="space-y-1"> {items.map((item) => ( <li key={item.id}> <button onClick={() => setActive(item.id)} title={collapsed ? item.label : undefined} aria-current={active === item.id ? "page" : undefined} className={`relative flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-colors ${active === item.id ? "text-white" : "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"}`} > {active === item.id && <motion.span layoutId="sidebar-active" className="absolute inset-0 rounded-xl bg-zinc-900" transition={{ type: "spring", stiffness: 420, damping: 34 }} />} <item.icon className="relative size-[18px] shrink-0" /> <AnimatePresence initial={false}> {!collapsed && ( <motion.span initial={{ opacity: 0, x: -6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -6 }} className="relative flex-1 whitespace-nowrap text-left"> {item.label} </motion.span> )} </AnimatePresence> {item.badge && ( <span className={`relative grid min-w-5 place-items-center rounded-full px-1.5 text-[10px] font-semibold ${collapsed ? "absolute right-1.5 top-1.5 min-w-4 px-1" : ""} ${active === item.id ? "bg-white text-zinc-900" : "bg-blue-600 text-white"}`}> {item.badge} </span> )} </button> </li> ))} </ul> </motion.nav> ); } export default function Demo() { return <CollapsibleSidebar />; }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.