"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface SidebarItem {
id: string;
label: string;
/** Icon shown in the rail. Recommended for top-level items. */
icon?: React.ReactNode;
/** Renders a link when given, otherwise a button. */
href?: string;
/** Small count or status shown after the label (a dot in the rail). */
badge?: string | number;
/** Nested items, rendered as a disclosure group. */
children?: SidebarItem[];
}
export interface CollapsibleSidebarProps
extends Omit<React.HTMLAttributes<HTMLElement>, "onSelect" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"> {
items: SidebarItem[];
/** Active item id (controlled). */
activeId?: string;
/** Initially active item id (uncontrolled). */
defaultActiveId?: string;
/** Called when an item is activated. Call event.preventDefault() for client routing. */
onSelect?: (item: SidebarItem, event: React.MouseEvent<HTMLElement>) => void;
/** Collapsed state (controlled). Persist it yourself if needed. */
collapsed?: boolean;
/** Initial collapsed state (uncontrolled). */
defaultCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
/** Width when expanded, in px. */
expandedWidth?: number;
/** Width of the icon rail, in px. */
collapsedWidth?: number;
/** Show labels as tooltips on hover / focus while collapsed. */
showTooltips?: boolean;
/** Key that toggles the sidebar together with ⌘ / Ctrl, e.g. "b". Empty string disables it. */
shortcut?: string;
/** Brand or workspace content beside the toggle. */
header?: React.ReactNode;
/** Content pinned to the bottom (account, settings…). */
footer?: React.ReactNode;
/** Accessible name of the nav landmark. */
label?: string;
/** Accessible name of the toggle button. */
toggleLabel?: string;
/** Spring for the width and the active indicator. */
spring?: Spring;
/** Accent for the active marker and focus rings. */
accent?: string;
}
function findParent(items: SidebarItem[], id: string | undefined): SidebarItem | undefined {
if (!id) return undefined;
return items.find((item) => item.children?.some((child) => child.id === id));
}
const ROW =
"relative flex h-10 w-full items-center overflow-hidden rounded-lg text-left text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--cs-accent)]";
/**
* An application sidebar that collapses to an icon rail with a spring width animation. Labels fade
* and slide, collapsed items show tooltips on hover and focus, nested groups are disclosures and a
* shared-layout indicator follows the active item. Controlled or uncontrolled.
*/
export function CollapsibleSidebar({
items,
activeId,
defaultActiveId,
onSelect,
collapsed,
defaultCollapsed = false,
onCollapsedChange,
expandedWidth = 248,
collapsedWidth = 64,
showTooltips = true,
shortcut = "",
header,
footer,
label = "Sidebar",
toggleLabel = "Toggle sidebar",
spring = { stiffness: 380, damping: 36, mass: 0.9 },
accent = "#FF7448",
className = "",
style,
...props
}: CollapsibleSidebarProps) {
const uid = React.useId();
const navId = `${uid}-nav`;
const reduceMotion = usePrefersReducedMotion();
const [internalCollapsed, setInternalCollapsed] = React.useState(defaultCollapsed);
const isCollapsed = collapsed ?? internalCollapsed;
const setCollapsed = (next: boolean) => {
if (collapsed === undefined) setInternalCollapsed(next);
onCollapsedChange?.(next);
};
const [internalActive, setInternalActive] = React.useState(defaultActiveId);
const current = activeId ?? internalActive;
// Groups start open when they contain the active item.
const [openGroups, setOpenGroups] = React.useState<Set<string>>(() => {
const parent = findParent(items, activeId ?? defaultActiveId);
return new Set(parent ? [parent.id] : []);
});
const toggleGroup = (id: string, force?: boolean) =>
setOpenGroups((prev) => {
const next = new Set(prev);
const open = force ?? !next.has(id);
if (open) next.add(id);
else next.delete(id);
return next;
});
// When the active child is hidden (rail or closed group), its parent carries the indicator.
const parentOfActive = findParent(items, current);
const visualActive = parentOfActive && (isCollapsed || !openGroups.has(parentOfActive.id)) ? parentOfActive.id : current;
const onShortcut = React.useEffectEvent(() => setCollapsed(!isCollapsed));
React.useEffect(() => {
if (!shortcut) return;
const key = shortcut.toLowerCase();
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
if (event.key.toLowerCase() !== key) return;
event.preventDefault();
onShortcut();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [shortcut]);
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const fade = reduceMotion ? { duration: 0 } : { duration: 0.18, ease: "easeOut" as const };
const select = (item: SidebarItem, event: React.MouseEvent<HTMLElement>) => {
if (activeId === undefined) setInternalActive(item.id);
onSelect?.(item, event);
};
const labelMotion = {
initial: false as const,
animate: { opacity: isCollapsed ? 0 : 1, x: isCollapsed ? -6 : 0 },
transition: fade,
};
const renderRow = (item: SidebarItem, depth: number) => {
const isGroup = Boolean(item.children?.length);
const groupOpen = isGroup && !isCollapsed && openGroups.has(item.id);
const isVisualActive = visualActive === item.id;
const isCurrent = current === item.id;
const groupId = `${uid}-group-${item.id}`;
const content = (
<>
{isVisualActive && (
<motion.span
aria-hidden="true"
layoutId={`${uid}-active`}
transition={transition}
className="absolute inset-0 rounded-lg bg-zinc-900/[0.06] dark:bg-white/[0.07]"
>
<span className="absolute bottom-2.5 left-0 top-2.5 w-[2px] rounded-full bg-[var(--cs-accent)]" />
</motion.span>
)}
<span
className="relative flex h-10 shrink-0 items-center justify-center text-zinc-500 group-hover/row:text-zinc-900 group-aria-[current=page]/row:text-zinc-950 dark:text-zinc-400 dark:group-hover/row:text-zinc-100 dark:group-aria-[current=page]/row:text-white [&_svg]:h-[18px] [&_svg]:w-[18px]"
style={{ width: "calc(var(--cs-rail) - 16px)" }}
>
{depth > 0 ? <span aria-hidden="true" className="h-1 w-1 rounded-full bg-current opacity-60" /> : item.icon}
{item.badge !== undefined && (
<motion.span
aria-hidden="true"
initial={false}
animate={{ opacity: isCollapsed ? 1 : 0, scale: isCollapsed ? 1 : 0.4 }}
transition={fade}
className="absolute right-[calc(50%-13px)] top-[9px] h-2 w-2 rounded-full border-2 border-white bg-[var(--cs-accent)] dark:border-zinc-900"
/>
)}
</span>
<motion.span {...labelMotion} className="relative flex min-w-0 flex-1 items-center gap-2 pr-2.5">
<span className="truncate font-medium tracking-tight">{item.label}</span>
{item.badge !== undefined && (
<span className="ml-auto rounded-full bg-zinc-900/[0.06] px-1.5 py-px font-mono text-[10px] tabular-nums text-zinc-600 dark:bg-white/[0.08] dark:text-zinc-300">
{item.badge}
</span>
)}
{isGroup && (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={`h-3.5 w-3.5 shrink-0 text-zinc-400 transition-transform duration-200 ${item.badge === undefined ? "ml-auto" : ""} ${groupOpen ? "rotate-90" : ""}`}
fill="none"
>
<path d="M6 3.5 10.5 8 6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</motion.span>
</>
);
const rowClass = `${ROW} group/row ${isVisualActive ? "text-zinc-950 dark:text-white" : "text-zinc-600 hover:bg-zinc-900/[0.04] hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-white/[0.04] dark:hover:text-white"}`;
let control: React.ReactNode;
if (isGroup) {
control = (
<button
type="button"
aria-expanded={groupOpen}
aria-controls={groupOpen ? groupId : undefined}
onClick={() => {
if (isCollapsed) {
// The rail has no room for children: expand and open the group.
setCollapsed(false);
toggleGroup(item.id, true);
} else toggleGroup(item.id);
}}
className={rowClass}
>
{content}
</button>
);
} else if (item.href) {
control = (
<a href={item.href} aria-current={isCurrent ? "page" : undefined} onClick={(event) => select(item, event)} className={rowClass}>
{content}
</a>
);
} else {
control = (
<button type="button" aria-current={isCurrent ? "page" : undefined} onClick={(event) => select(item, event)} className={rowClass}>
{content}
</button>
);
}
return (
<li key={item.id} className="group/item relative">
{control}
{isCollapsed && showTooltips && depth === 0 && (
// The label stays in the control's accessible name, so the tooltip is visual only.
<span
aria-hidden="true"
className="pointer-events-none absolute left-full top-1/2 z-20 ml-3 -translate-y-1/2 scale-95 whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-xs font-medium text-white opacity-0 shadow-lg transition-[opacity,scale] duration-150 group-hover/item:scale-100 group-hover/item:opacity-100 group-has-[:focus-visible]/item:scale-100 group-has-[:focus-visible]/item:opacity-100 dark:bg-white dark:text-zinc-950"
>
{item.label}
{item.badge !== undefined && <span className="ml-1.5 font-mono text-[10px] opacity-60">{item.badge}</span>}
</span>
)}
{isGroup && (
<AnimatePresence initial={false}>
{groupOpen && (
<motion.ul
id={groupId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
{item.children?.map((child) => renderRow(child, depth + 1))}
</motion.ul>
)}
</AnimatePresence>
)}
</li>
);
};
return (
<motion.aside
initial={false}
animate={{ width: isCollapsed ? collapsedWidth : expandedWidth }}
transition={transition}
data-collapsed={isCollapsed || undefined}
className={`relative flex h-full shrink-0 flex-col border-r border-zinc-900/10 bg-white text-zinc-900 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 ${className}`}
style={{ ["--cs-accent" as string]: accent, ["--cs-rail" as string]: `${collapsedWidth}px`, ...style }}
{...props}
>
<div className="flex h-14 shrink-0 items-center overflow-hidden border-b border-zinc-900/[0.07] px-2 dark:border-white/[0.07]">
<button
type="button"
aria-label={toggleLabel}
aria-expanded={!isCollapsed}
aria-controls={navId}
aria-keyshortcuts={shortcut ? `Control+${shortcut.toUpperCase()} Meta+${shortcut.toUpperCase()}` : undefined}
onClick={() => setCollapsed(!isCollapsed)}
className="flex h-10 shrink-0 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--cs-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white"
style={{ width: "calc(var(--cs-rail) - 16px)" }}
>
<svg viewBox="0 0 20 20" className="h-[18px] w-[18px]" aria-hidden="true" fill="none">
<rect x="2.75" y="3.75" width="14.5" height="12.5" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<motion.path
d="M7.5 4v12"
stroke="currentColor"
strokeWidth="1.5"
initial={false}
animate={{ x: isCollapsed ? -1.5 : 0 }}
transition={transition}
/>
</svg>
</button>
{header && (
<motion.div {...labelMotion} className="min-w-0 flex-1 pl-1">
{header}
</motion.div>
)}
</div>
<nav
id={navId}
aria-label={label}
className={`min-h-0 flex-1 px-2 py-3 ${isCollapsed ? "overflow-visible" : "overflow-y-auto overflow-x-hidden"}`}
>
<ul className="flex flex-col gap-0.5">{items.map((item) => renderRow(item, 0))}</ul>
</nav>
{footer && (
<div className="shrink-0 overflow-hidden border-t border-zinc-900/[0.07] p-2 dark:border-white/[0.07]">{footer}</div>
)}
</motion.aside>
);
}