Collapsible Sidebar

App sidebar that animates between full and icon-only widths.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/collapsible-sidebar.tsx

    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 />;
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.