# Sidebar

Collapsible app sidebar: width springs between 240 and 64 px, labels fade, and an ember bar marks the active item.

**Category:** Navigation  
**Exports:** `Sidebar`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/sidebar.json
```
The file lands at `components/ui/sidebar.tsx`; import from `@/components/ui/sidebar`.

## API — Sidebar
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `sections` | `{ title?: string; items: { id, label, icon, badge? }[] }[]` | — |  |
| `active / onSelect` | `string / (id: string) => void` | — |  |
| `collapsed / defaultCollapsed` | `boolean` | — |  |
| `onCollapsedChange` | `(collapsed: boolean) => void` | — |  |
| `header` | `ReactNode` | — |  |

## Example — Sidebar

```tsx
import * as React from "react";
import { BarChart3, FolderKanban, Home, Inbox, Settings, Users } from "lucide-react";
import { Sidebar } from "@/components/ui/sidebar";

export default function SidebarDemo({ header = "Charcoal" }: { header?: string }) {
  const [active, setActive] = React.useState("inbox");
  return (
    <div className="flex h-96 w-full overflow-hidden rounded-xl border bg-background">
      <Sidebar
        header={header}
        active={active}
        onSelect={setActive}
        sections={[
          {
            items: [
              { id: "home", label: "Home", icon: <Home /> },
              { id: "inbox", label: "Inbox", icon: <Inbox />, badge: 12 },
              { id: "projects", label: "Projects", icon: <FolderKanban /> },
            ],
          },
          {
            title: "Workspace",
            items: [
              { id: "team", label: "Team", icon: <Users /> },
              { id: "reports", label: "Reports", icon: <BarChart3 /> },
              { id: "settings", label: "Settings", icon: <Settings /> },
            ],
          },
        ]}
      />
      <main className="grid flex-1 place-items-center p-6 text-sm text-muted-foreground">
        <span className="capitalize">{active}</span>
      </main>
    </div>
  );
}
```

## Source — components/ui/sidebar.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type SidebarItem = { id: string; label: string; icon: React.ReactNode; badge?: string | number };
export type SidebarSection = { title?: string; items: SidebarItem[] };

export type SidebarProps = {
  sections: SidebarSection[];
  active?: string;
  onSelect?: (id: string) => void;
  collapsed?: boolean;
  defaultCollapsed?: boolean;
  onCollapsedChange?: (collapsed: boolean) => void;
  header?: React.ReactNode;
  className?: string;
};

export function Sidebar({ sections, active, onSelect, collapsed: collapsedProp, defaultCollapsed = false, onCollapsedChange, header, className }: SidebarProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(defaultCollapsed);
  const collapsed = collapsedProp ?? inner;
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 400, damping: 38 };

  const toggle = () => {
    if (collapsedProp === undefined) setInner(!collapsed);
    onCollapsedChange?.(!collapsed);
  };

  return (
    <motion.aside
      initial={false}
      animate={{ width: collapsed ? 64 : 240 }}
      transition={spring}
      className={cn("flex h-full shrink-0 flex-col overflow-hidden border-r bg-card", className)}
    >
      <div className="flex h-14 items-center justify-between gap-2 px-3">
        <AnimatePresence>
          {!collapsed && (
            <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="min-w-0 truncate pl-1 font-semibold">
              {header}
            </motion.div>
          )}
        </AnimatePresence>
        <button
          type="button"
          onClick={toggle}
          aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
          aria-expanded={!collapsed}
          className="grid size-9 shrink-0 cursor-pointer place-items-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
        >
          <motion.svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" animate={{ rotate: collapsed ? 180 : 0 }} transition={spring} aria-hidden>
            <rect x="3.5" y="4.5" width="17" height="15" rx="2.5" />
            <path d="M9 4.5v15M15.5 10l-2 2 2 2" />
          </motion.svg>
        </button>
      </div>
      <nav aria-label="Sidebar" className="flex-1 space-y-5 overflow-y-auto overflow-x-hidden px-3 pb-4">
        {sections.map((section, si) => (
          <div key={si}>
            {section.title && (
              <p className={cn("mb-1.5 h-4 px-2.5 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground transition-opacity", collapsed && "opacity-0")}>
                {section.title}
              </p>
            )}
            <ul className="space-y-0.5">
              {section.items.map((item) => {
                const isActive = item.id === active;
                return (
                  <li key={item.id}>
                    <button
                      type="button"
                      onClick={() => onSelect?.(item.id)}
                      aria-current={isActive ? "page" : undefined}
                      title={collapsed ? item.label : undefined}
                      className={cn(
                        "relative flex h-9 w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                        isActive ? "font-medium text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
                      )}
                    >
                      {isActive && <motion.span layoutId={`${id}-active`} className="absolute inset-0 rounded-lg bg-accent" transition={spring} />}
                      {isActive && <motion.span layoutId={`${id}-bar`} className="absolute -left-3 top-2 h-5 w-[3px] rounded-r-full bg-ember" transition={spring} />}
                      <span className="relative z-10 grid size-[18px] shrink-0 place-items-center [&_svg]:size-[18px]">{item.icon}</span>
                      <motion.span
                        className="relative z-10 flex-1 truncate text-left"
                        animate={{ opacity: collapsed ? 0 : 1, x: collapsed && !reduce ? -6 : 0 }}
                        transition={{ duration: 0.15 }}
                      >
                        {item.label}
                      </motion.span>
                      {item.badge !== undefined && (
                        <motion.span
                          animate={{ opacity: collapsed ? 0 : 1 }}
                          className="relative z-10 rounded-md bg-secondary px-1.5 font-mono text-[11px] text-muted-foreground"
                        >
                          {item.badge}
                        </motion.span>
                      )}
                      {item.badge !== undefined && collapsed && <span className="absolute right-2 top-1.5 size-1.5 rounded-full bg-ember" aria-hidden />}
                    </button>
                  </li>
                );
              })}
            </ul>
          </div>
        ))}
      </nav>
    </motion.aside>
  );
}
```