AI Sidebar
A collapsible AI workspace sidebar for folders, projects, files, and bookmarks with keyboard navigation, optimistic moves, inline rename, and overflow-aware labels.
Preview
"use client";
import { useState } from "react";
import {
AnimatedSidebar,
AnimatedSidebarContent,
AnimatedSidebarGroup,
AnimatedSidebarGroupContent,
AnimatedSidebarGroupLabel,
AnimatedSidebarProvider,
} from "@/components/motion/animated-sidebar";
import {
AISidebar,
type SidebarResource,
} from "@/components/agents/ai-sidebar";
const resources: SidebarResource[] = [
{
id: "project",
label: "Website redesign",
kind: "project",
children: [
{ id: "brief", label: "Project brief", kind: "file" },
{ id: "research", label: "Research links", kind: "bookmark" },
],
},
{ id: "archive", label: "Archive", kind: "folder" },
];
export function AISidebarUsage() {
const [items, setItems] = useState(resources);
return (
<AnimatedSidebarProvider>
<AnimatedSidebar
ariaLabel="Project resources"
collapsible="offcanvas"
>
<AnimatedSidebarContent>
<AnimatedSidebarGroup>
<AnimatedSidebarGroupLabel>Resources</AnimatedSidebarGroupLabel>
<AnimatedSidebarGroupContent>
<AISidebar
items={items}
onItemsChange={setItems}
defaultExpandedIds={["project"]}
onMove={async (move) => {
await saveResourceMove(move);
}}
/>
</AnimatedSidebarGroupContent>
</AnimatedSidebarGroup>
</AnimatedSidebarContent>
</AnimatedSidebar>
</AnimatedSidebarProvider>
);
}
async function saveResourceMove(_move: unknown) {
// Persist the move. Rejecting this promise restores the previous tree.
}
"use client";
// beui.dev/components/agents/ai-sidebar
import {
Bookmark,
FileText,
Folder,
FolderOpen,
MoreHorizontal,
Pencil,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type DragEvent,
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type SidebarResourceKind =
| "folder"
| "project"
| "file"
| "bookmark";
export interface SidebarResource {
id: string;
label: string;
kind: SidebarResourceKind;
children?: SidebarResource[];
disabled?: boolean;
}
export type SidebarResourceDropPosition = "before" | "inside" | "after";
export interface SidebarResourceMove {
itemId: string;
targetId: string | null;
position: SidebarResourceDropPosition;
}
export interface SidebarResourceMenuControls {
close: () => void;
rename: () => void;
}
export interface AISidebarProps {
items?: SidebarResource[];
defaultItems?: SidebarResource[];
onItemsChange?: (items: SidebarResource[]) => void;
/** Reject the promise to roll the optimistic move back. */
onMove?: (move: SidebarResourceMove) => void | Promise<void>;
onMoveError?: (error: unknown, move: SidebarResourceMove) => void;
onRename?: (item: SidebarResource, label: string) => void | Promise<void>;
activeId?: string | null;
defaultActiveId?: string | null;
onActiveChange?: (id: string) => void;
defaultExpandedIds?: string[];
renderIcon?: (item: SidebarResource) => ReactNode;
renderMenu?: (
item: SidebarResource,
controls: SidebarResourceMenuControls,
) => ReactNode;
ariaLabel?: string;
className?: string;
}
interface FlatResource {
item: SidebarResource;
depth: number;
parentId: string | null;
}
interface DropTarget {
id: string | null;
position: SidebarResourceDropPosition;
}
const ROW_REVEAL = {
duration: 0.16,
ease: EASE_OUT,
} as const;
function canContain(item: SidebarResource) {
return item.kind === "folder" || item.kind === "project";
}
function flattenResources(
items: SidebarResource[],
expanded: Set<string>,
depth = 0,
parentId: string | null = null,
): FlatResource[] {
return items.flatMap((item) => {
const row = { item, depth, parentId };
if (!item.children?.length || !expanded.has(item.id)) return [row];
return [
row,
...flattenResources(item.children, expanded, depth + 1, item.id),
];
});
}
function findResource(
items: SidebarResource[],
id: string,
): SidebarResource | undefined {
for (const item of items) {
if (item.id === id) return item;
const child = item.children ? findResource(item.children, id) : undefined;
if (child) return child;
}
}
function containsResource(item: SidebarResource, id: string): boolean {
return (
item.id === id ||
item.children?.some((child) => containsResource(child, id)) === true
);
}
function removeResource(
items: SidebarResource[],
id: string,
): { items: SidebarResource[]; removed?: SidebarResource } {
let removed: SidebarResource | undefined;
const next: SidebarResource[] = [];
for (const item of items) {
if (item.id === id) {
removed = item;
continue;
}
if (item.children?.length) {
const childResult = removeResource(item.children, id);
if (childResult.removed) {
removed = childResult.removed;
next.push({ ...item, children: childResult.items });
continue;
}
}
next.push(item);
}
return { items: next, removed };
}
function insertResource(
items: SidebarResource[],
resource: SidebarResource,
targetId: string | null,
position: SidebarResourceDropPosition,
): SidebarResource[] {
if (targetId === null) return [...items, resource];
const next: SidebarResource[] = [];
for (const item of items) {
if (item.id === targetId) {
if (position === "before") next.push(resource, item);
else if (position === "after") next.push(item, resource);
else next.push({ ...item, children: [...(item.children ?? []), resource] });
continue;
}
if (item.children?.length) {
next.push({
...item,
children: insertResource(item.children, resource, targetId, position),
});
} else {
next.push(item);
}
}
return next;
}
function moveResource(
items: SidebarResource[],
move: SidebarResourceMove,
): SidebarResource[] | null {
const source = findResource(items, move.itemId);
if (!source || source.disabled) return null;
if (move.targetId && containsResource(source, move.targetId)) return null;
const target = move.targetId ? findResource(items, move.targetId) : undefined;
if (
move.position === "inside" &&
(!target || target.disabled || !canContain(target))
)
return null;
const removed = removeResource(items, move.itemId);
if (!removed.removed) return null;
return insertResource(
removed.items,
removed.removed,
move.targetId,
move.position,
);
}
function renameResource(
items: SidebarResource[],
id: string,
label: string,
): SidebarResource[] {
return items.map((item) => ({
...item,
label: item.id === id ? label : item.label,
children: item.children
? renameResource(item.children, id, label)
: undefined,
}));
}
function defaultIcon(item: SidebarResource, expanded: boolean) {
const Icon =
item.kind === "folder" || item.kind === "project"
? expanded
? FolderOpen
: Folder
: item.kind === "bookmark"
? Bookmark
: FileText;
return <Icon className="size-4" />;
}
function MarqueeLabel({ active, children }: { active: boolean; children: string }) {
const reduce = useReducedMotion() ?? false;
const viewportRef = useRef<HTMLSpanElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const [distance, setDistance] = useState(0);
useEffect(() => {
const measure = () => {
const viewport = viewportRef.current;
const label = labelRef.current;
if (!viewport || !label) return;
setDistance(label.scrollWidth > viewport.clientWidth ? label.scrollWidth + 24 : 0);
};
measure();
const observer = new ResizeObserver(measure);
if (viewportRef.current) observer.observe(viewportRef.current);
if (labelRef.current) observer.observe(labelRef.current);
return () => observer.disconnect();
}, []);
const running = active && distance > 0 && !reduce;
return (
<span ref={viewportRef} className="block min-w-0 flex-1 overflow-hidden">
<motion.span
className="flex w-max items-center gap-6 whitespace-nowrap"
animate={{ x: running ? [0, -distance] : 0 }}
transition={
running
? {
duration: Math.max(2.4, distance / 34),
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 2,
}
: ROW_REVEAL
}
>
<span ref={labelRef}>{children}</span>
{running ? <span aria-hidden="true">{children}</span> : null}
</motion.span>
</span>
);
}
interface ResourceRowProps {
row: FlatResource;
active: boolean;
expanded: boolean;
focused: boolean;
draggingId: string | null;
dropTarget: DropTarget | null;
menuOpen: boolean;
renaming: boolean;
onDragEnd: () => void;
onDragOver: (event: DragEvent<HTMLDivElement>, row: FlatResource) => void;
onDragStart: (event: DragEvent<HTMLDivElement>, id: string) => void;
onDrop: (event: DragEvent<HTMLDivElement>) => void;
onFocus: () => void;
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
onMenuOpenChange: (open: boolean) => void;
onRenameCancel: () => void;
onRenameCommit: (label: string) => void;
onRenameStart: () => void;
onSelect: () => void;
onToggle: () => void;
renderIcon?: (item: SidebarResource) => ReactNode;
renderMenu?: AISidebarProps["renderMenu"];
setRef: (node: HTMLDivElement | null) => void;
}
function ResourceRow({
row,
active,
expanded,
focused,
draggingId,
dropTarget,
menuOpen,
renaming,
onDragEnd,
onDragOver,
onDragStart,
onDrop,
onFocus,
onKeyDown,
onMenuOpenChange,
onRenameCancel,
onRenameCommit,
onRenameStart,
onSelect,
onToggle,
renderIcon,
renderMenu,
setRef,
}: ResourceRowProps) {
const reduce = useReducedMotion() ?? false;
const [hovered, setHovered] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const skipRenameBlurRef = useRef(false);
const draggedRef = useRef(false);
const [draft, setDraft] = useState(row.item.label);
const acceptsChildren = canContain(row.item);
const isDragging = draggingId === row.item.id;
const dropPosition = dropTarget?.id === row.item.id ? dropTarget.position : null;
useEffect(() => {
if (!renaming) return;
skipRenameBlurRef.current = false;
setDraft(row.item.label);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}, [renaming, row.item.label]);
const menu = renderMenu?.(row.item, {
close: () => onMenuOpenChange(false),
rename: () => {
onMenuOpenChange(false);
onRenameStart();
},
}) ?? (
<button
type="button"
onClick={() => {
onMenuOpenChange(false);
onRenameStart();
}}
className="flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left text-xs text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil aria-hidden="true" className="size-3.5" />
Rename
</button>
);
return (
<motion.div
ref={setRef}
layout="position"
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
role="treeitem"
aria-level={row.depth + 1}
aria-selected={acceptsChildren ? undefined : active}
aria-expanded={acceptsChildren ? expanded : undefined}
aria-disabled={row.item.disabled || undefined}
tabIndex={focused ? 0 : -1}
draggable={!row.item.disabled && !renaming}
data-menu-open={menuOpen || undefined}
data-drop={dropPosition ?? undefined}
data-dragging={isDragging || undefined}
onFocus={onFocus}
onKeyDown={onKeyDown}
onClick={(event) => {
if (
event.defaultPrevented ||
draggedRef.current ||
renaming ||
row.item.disabled
)
return;
if (acceptsChildren) onToggle();
else onSelect();
}}
onDoubleClick={(event) => {
if (acceptsChildren || row.item.disabled) return;
event.preventDefault();
onRenameStart();
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onDragStartCapture={(event) => {
draggedRef.current = true;
onDragStart(event, row.item.id);
}}
onDragEndCapture={() => {
onDragEnd();
requestAnimationFrame(() => {
draggedRef.current = false;
});
}}
onDragOver={(event) => onDragOver(event, row)}
onDrop={onDrop}
className={cn(
"group/resource relative flex min-h-9 min-w-0 cursor-pointer items-center gap-2.5 rounded-xl pr-3 text-sm outline-none",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"data-[menu-open=true]:bg-muted data-[menu-open=true]:text-foreground",
"data-[dragging=true]:opacity-40",
"data-[drop=inside]:bg-primary/10 data-[drop=inside]:ring-1 data-[drop=inside]:ring-primary/45",
"data-[drop=before]:before:absolute data-[drop=before]:before:-top-0.5 data-[drop=before]:before:right-2 data-[drop=before]:before:left-2 data-[drop=before]:before:h-0.5 data-[drop=before]:before:rounded-full data-[drop=before]:before:bg-primary",
"data-[drop=after]:after:absolute data-[drop=after]:after:-bottom-0.5 data-[drop=after]:after:right-2 data-[drop=after]:after:left-2 data-[drop=after]:after:h-0.5 data-[drop=after]:after:rounded-full data-[drop=after]:after:bg-primary",
!acceptsChildren && active && "bg-muted text-foreground",
row.item.disabled && "cursor-not-allowed opacity-45",
)}
style={{ paddingLeft: `${12 + row.depth * 16}px` }}
>
<span aria-hidden="true" className="grid size-5 shrink-0 place-items-center">
{renderIcon?.(row.item) ?? defaultIcon(row.item, expanded)}
</span>
{renaming ? (
<input
ref={inputRef}
value={draft}
aria-label={`Rename ${row.item.label}`}
onChange={(event) => setDraft(event.target.value)}
draggable={false}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onBlur={() => {
if (!skipRenameBlurRef.current) onRenameCommit(draft);
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
skipRenameBlurRef.current = true;
onRenameCommit(draft);
}
if (event.key === "Escape") {
skipRenameBlurRef.current = true;
onRenameCancel();
}
}}
className="mx-1 h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
) : (
<MarqueeLabel active={hovered || menuOpen}>{row.item.label}</MarqueeLabel>
)}
{!renaming && !row.item.disabled ? (
<MorphPopover
open={menuOpen}
onOpenChange={onMenuOpenChange}
>
<MorphPopoverTrigger>
<button
type="button"
draggable={false}
tabIndex={-1}
aria-label={`Actions for ${row.item.label}`}
onClick={(event) => event.stopPropagation()}
className="grid size-7 shrink-0 place-items-center rounded-lg opacity-0 outline-none transition-opacity hover:bg-foreground/5 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover/resource:opacity-100 group-data-[menu-open=true]/resource:opacity-100"
>
<MoreHorizontal aria-hidden="true" className="size-4" />
</button>
</MorphPopoverTrigger>
<MorphPopoverContent
side="bottom"
align="end"
sideOffset={8}
radius={12}
className="w-40 p-1.5"
>
<div data-sidebar-resource-menu={row.item.id}>{menu}</div>
</MorphPopoverContent>
</MorphPopover>
) : null}
</motion.div>
);
}
export function AISidebar({
items,
defaultItems = [],
onItemsChange,
onMove,
onMoveError,
onRename,
activeId,
defaultActiveId = null,
onActiveChange,
defaultExpandedIds = [],
renderIcon,
renderMenu,
ariaLabel = "Resources",
className,
}: AISidebarProps) {
const [internalItems, setInternalItems] = useState(items ?? defaultItems);
const [internalActiveId, setInternalActiveId] = useState(defaultActiveId);
const [expandedIds, setExpandedIds] = useState(
() => new Set(defaultExpandedIds),
);
const [focusedId, setFocusedId] = useState<string | null>(
activeId ?? defaultActiveId,
);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState("");
const rowRefs = useRef(new Map<string, HTMLDivElement>());
const movePendingRef = useRef(false);
const renderedItems = internalItems;
const selectedId = activeId ?? internalActiveId;
useEffect(() => {
if (items) setInternalItems(items);
}, [items]);
const flat = useMemo(
() => flattenResources(renderedItems, expandedIds),
[expandedIds, renderedItems],
);
useEffect(() => {
if (focusedId && flat.some((row) => row.item.id === focusedId)) return;
setFocusedId(flat[0]?.item.id ?? null);
}, [flat, focusedId]);
useEffect(() => {
if (!menuOpenId) return;
const frame = requestAnimationFrame(() => {
const menus = Array.from(
document.querySelectorAll<HTMLElement>("[data-sidebar-resource-menu]"),
);
menus
.find((menu) => menu.dataset.sidebarResourceMenu === menuOpenId)
?.querySelector<HTMLElement>("button, a[href]")
?.focus();
});
return () => cancelAnimationFrame(frame);
}, [menuOpenId]);
const updateItems = useCallback(
(next: SidebarResource[]) => {
setInternalItems(next);
onItemsChange?.(next);
},
[onItemsChange],
);
const performMove = useCallback(
async (move: SidebarResourceMove) => {
if (movePendingRef.current) {
setAnnouncement("Wait for the current move to finish.");
return;
}
const before = renderedItems;
const next = moveResource(before, move);
if (!next || next === before) return;
movePendingRef.current = true;
updateItems(next);
setDropTarget(null);
setDraggingId(null);
const moved = findResource(before, move.itemId);
const target = move.targetId ? findResource(before, move.targetId) : null;
setAnnouncement(
target
? `Moved ${moved?.label ?? "item"} ${move.position} ${target.label}.`
: `Moved ${moved?.label ?? "item"} to the top level.`,
);
try {
await onMove?.(move);
} catch (error) {
updateItems(before);
setAnnouncement(`Move failed. ${moved?.label ?? "Item"} was restored.`);
onMoveError?.(error, move);
} finally {
movePendingRef.current = false;
}
},
[onMove, onMoveError, renderedItems, updateItems],
);
const focusRow = useCallback((id: string) => {
setFocusedId(id);
requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
}, []);
const select = useCallback(
(id: string) => {
if (activeId === undefined) setInternalActiveId(id);
onActiveChange?.(id);
},
[activeId, onActiveChange],
);
const toggle = useCallback((id: string) => {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>, row: FlatResource) => {
const index = flat.findIndex(({ item }) => item.id === row.item.id);
const previous = flat[index - 1];
const next = flat[index + 1];
const moveModifier = event.altKey && event.shiftKey;
if (event.key === "ArrowDown" && !moveModifier && next) {
event.preventDefault();
focusRow(next.item.id);
return;
}
if (event.key === "ArrowUp" && !moveModifier && previous) {
event.preventDefault();
focusRow(previous.item.id);
return;
}
if (event.key === "Home" && flat[0]) {
event.preventDefault();
focusRow(flat[0].item.id);
return;
}
if (event.key === "End" && flat.at(-1)) {
event.preventDefault();
focusRow(flat.at(-1)?.item.id ?? row.item.id);
return;
}
if (row.item.disabled) {
if (event.key === "ArrowLeft" && row.parentId) {
event.preventDefault();
focusRow(row.parentId);
} else if (
moveModifier ||
["ArrowRight", "Enter", " ", "F2", "ContextMenu"].includes(
event.key,
) ||
(event.shiftKey && event.key === "F10")
) {
event.preventDefault();
}
return;
}
if (moveModifier && event.key === "ArrowUp" && previous) {
event.preventDefault();
void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "before" });
return;
}
if (moveModifier && event.key === "ArrowDown" && next) {
event.preventDefault();
void performMove({ itemId: row.item.id, targetId: next.item.id, position: "after" });
return;
}
if (moveModifier && event.key === "ArrowRight" && previous && canContain(previous.item)) {
event.preventDefault();
setExpandedIds((current) => new Set(current).add(previous.item.id));
void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "inside" });
return;
}
if (moveModifier && event.key === "ArrowLeft" && row.parentId) {
event.preventDefault();
void performMove({ itemId: row.item.id, targetId: row.parentId, position: "after" });
return;
}
if (event.key === "ArrowRight" && canContain(row.item)) {
event.preventDefault();
if (!expandedIds.has(row.item.id)) toggle(row.item.id);
else if (next?.parentId === row.item.id) focusRow(next.item.id);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
if (expandedIds.has(row.item.id)) toggle(row.item.id);
else if (row.parentId) focusRow(row.parentId);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (canContain(row.item)) toggle(row.item.id);
else select(row.item.id);
} else if (event.key === "F2") {
event.preventDefault();
setRenamingId(row.item.id);
} else if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
event.preventDefault();
setMenuOpenId(row.item.id);
}
},
[expandedIds, flat, focusRow, performMove, select, toggle],
);
return (
<>
<div
role="tree"
aria-label={ariaLabel}
aria-multiselectable="false"
onDragOver={(event) => {
if (!draggingId || event.target !== event.currentTarget) return;
event.preventDefault();
setDropTarget({ id: null, position: "after" });
}}
onDrop={(event) => {
event.preventDefault();
if (draggingId && dropTarget) {
void performMove({
itemId: draggingId,
targetId: dropTarget.id,
position: dropTarget.position,
});
}
}}
className={cn(
"relative flex min-w-0 flex-col gap-0.5 [overflow-anchor:none] group-data-[state=collapsed]/sidebar:hidden",
draggingId && "select-none pb-9",
className,
)}
>
<AnimatePresence initial={false}>
{flat.map((row) => (
<ResourceRow
key={row.item.id}
row={row}
active={selectedId === row.item.id}
expanded={expandedIds.has(row.item.id)}
focused={focusedId === row.item.id}
draggingId={draggingId}
dropTarget={dropTarget}
menuOpen={menuOpenId === row.item.id}
renaming={renamingId === row.item.id}
onFocus={() => setFocusedId(row.item.id)}
onSelect={() => select(row.item.id)}
onToggle={() => toggle(row.item.id)}
onKeyDown={(event) => handleKeyDown(event, row)}
onRenameStart={() => setRenamingId(row.item.id)}
onRenameCancel={() => setRenamingId(null)}
onRenameCommit={(label) => {
const trimmed = label.trim();
setRenamingId(null);
if (!trimmed || trimmed === row.item.label) return;
const before = renderedItems;
updateItems(renameResource(before, row.item.id, trimmed));
void Promise.resolve(onRename?.(row.item, trimmed)).catch(() => {
updateItems(before);
setAnnouncement(`Rename failed. ${row.item.label} was restored.`);
});
}}
onMenuOpenChange={(open) => {
setMenuOpenId(open ? row.item.id : null);
if (!open) focusRow(row.item.id);
}}
onDragStart={(event, id) => {
setDraggingId(id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", id);
}}
onDragEnd={() => {
setDraggingId(null);
setDropTarget(null);
}}
onDragOver={(event, targetRow) => {
if (!draggingId || draggingId === targetRow.item.id) return;
const source = findResource(renderedItems, draggingId);
if (source && containsResource(source, targetRow.item.id)) return;
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
const position =
!targetRow.item.disabled &&
canContain(targetRow.item) &&
ratio >= 0.25 &&
ratio <= 0.75
? "inside"
: ratio < 0.5
? "before"
: "after";
setDropTarget({ id: targetRow.item.id, position });
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
if (draggingId && dropTarget) {
void performMove({
itemId: draggingId,
targetId: dropTarget.id,
position: dropTarget.position,
});
}
}}
renderIcon={renderIcon}
renderMenu={renderMenu}
setRef={(node) => {
if (node) rowRefs.current.set(row.item.id, node);
else rowRefs.current.delete(row.item.id);
}}
/>
))}
</AnimatePresence>
{draggingId ? (
<div
aria-hidden="true"
data-active={dropTarget?.id === null || undefined}
className="absolute inset-x-1 bottom-0 flex h-8 items-center justify-center rounded-lg border border-dashed border-border text-[10px] text-muted-foreground data-[active=true]:border-primary/50 data-[active=true]:bg-primary/10 data-[active=true]:text-foreground"
>
Move to top level
</div>
) : null}
</div>
<span className="sr-only" aria-live="polite">
{announcement}
</span>
</>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/agents/ai-sidebar
import {
Bookmark,
FileText,
Folder,
FolderOpen,
MoreHorizontal,
Pencil,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type DragEvent,
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type SidebarResourceKind =
| "folder"
| "project"
| "file"
| "bookmark";
export interface SidebarResource {
id: string;
label: string;
kind: SidebarResourceKind;
children?: SidebarResource[];
disabled?: boolean;
}
export type SidebarResourceDropPosition = "before" | "inside" | "after";
export interface SidebarResourceMove {
itemId: string;
targetId: string | null;
position: SidebarResourceDropPosition;
}
export interface SidebarResourceMenuControls {
close: () => void;
rename: () => void;
}
export interface AISidebarProps {
items?: SidebarResource[];
defaultItems?: SidebarResource[];
onItemsChange?: (items: SidebarResource[]) => void;
/** Reject the promise to roll the optimistic move back. */
onMove?: (move: SidebarResourceMove) => void | Promise<void>;
onMoveError?: (error: unknown, move: SidebarResourceMove) => void;
onRename?: (item: SidebarResource, label: string) => void | Promise<void>;
activeId?: string | null;
defaultActiveId?: string | null;
onActiveChange?: (id: string) => void;
defaultExpandedIds?: string[];
renderIcon?: (item: SidebarResource) => ReactNode;
renderMenu?: (
item: SidebarResource,
controls: SidebarResourceMenuControls,
) => ReactNode;
ariaLabel?: string;
className?: string;
}
interface FlatResource {
item: SidebarResource;
depth: number;
parentId: string | null;
}
interface DropTarget {
id: string | null;
position: SidebarResourceDropPosition;
}
const ROW_REVEAL = {
duration: 0.16,
ease: EASE_OUT,
} as const;
function canContain(item: SidebarResource) {
return item.kind === "folder" || item.kind === "project";
}
function flattenResources(
items: SidebarResource[],
expanded: Set<string>,
depth = 0,
parentId: string | null = null,
): FlatResource[] {
return items.flatMap((item) => {
const row = { item, depth, parentId };
if (!item.children?.length || !expanded.has(item.id)) return [row];
return [
row,
...flattenResources(item.children, expanded, depth + 1, item.id),
];
});
}
function findResource(
items: SidebarResource[],
id: string,
): SidebarResource | undefined {
for (const item of items) {
if (item.id === id) return item;
const child = item.children ? findResource(item.children, id) : undefined;
if (child) return child;
}
}
function containsResource(item: SidebarResource, id: string): boolean {
return (
item.id === id ||
item.children?.some((child) => containsResource(child, id)) === true
);
}
function removeResource(
items: SidebarResource[],
id: string,
): { items: SidebarResource[]; removed?: SidebarResource } {
let removed: SidebarResource | undefined;
const next: SidebarResource[] = [];
for (const item of items) {
if (item.id === id) {
removed = item;
continue;
}
if (item.children?.length) {
const childResult = removeResource(item.children, id);
if (childResult.removed) {
removed = childResult.removed;
next.push({ ...item, children: childResult.items });
continue;
}
}
next.push(item);
}
return { items: next, removed };
}
function insertResource(
items: SidebarResource[],
resource: SidebarResource,
targetId: string | null,
position: SidebarResourceDropPosition,
): SidebarResource[] {
if (targetId === null) return [...items, resource];
const next: SidebarResource[] = [];
for (const item of items) {
if (item.id === targetId) {
if (position === "before") next.push(resource, item);
else if (position === "after") next.push(item, resource);
else next.push({ ...item, children: [...(item.children ?? []), resource] });
continue;
}
if (item.children?.length) {
next.push({
...item,
children: insertResource(item.children, resource, targetId, position),
});
} else {
next.push(item);
}
}
return next;
}
function moveResource(
items: SidebarResource[],
move: SidebarResourceMove,
): SidebarResource[] | null {
const source = findResource(items, move.itemId);
if (!source || source.disabled) return null;
if (move.targetId && containsResource(source, move.targetId)) return null;
const target = move.targetId ? findResource(items, move.targetId) : undefined;
if (
move.position === "inside" &&
(!target || target.disabled || !canContain(target))
)
return null;
const removed = removeResource(items, move.itemId);
if (!removed.removed) return null;
return insertResource(
removed.items,
removed.removed,
move.targetId,
move.position,
);
}
function renameResource(
items: SidebarResource[],
id: string,
label: string,
): SidebarResource[] {
return items.map((item) => ({
...item,
label: item.id === id ? label : item.label,
children: item.children
? renameResource(item.children, id, label)
: undefined,
}));
}
function defaultIcon(item: SidebarResource, expanded: boolean) {
const Icon =
item.kind === "folder" || item.kind === "project"
? expanded
? FolderOpen
: Folder
: item.kind === "bookmark"
? Bookmark
: FileText;
return <Icon className="size-4" />;
}
function MarqueeLabel({ active, children }: { active: boolean; children: string }) {
const reduce = useReducedMotion() ?? false;
const viewportRef = useRef<HTMLSpanElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const [distance, setDistance] = useState(0);
useEffect(() => {
const measure = () => {
const viewport = viewportRef.current;
const label = labelRef.current;
if (!viewport || !label) return;
setDistance(label.scrollWidth > viewport.clientWidth ? label.scrollWidth + 24 : 0);
};
measure();
const observer = new ResizeObserver(measure);
if (viewportRef.current) observer.observe(viewportRef.current);
if (labelRef.current) observer.observe(labelRef.current);
return () => observer.disconnect();
}, []);
const running = active && distance > 0 && !reduce;
return (
<span ref={viewportRef} className="block min-w-0 flex-1 overflow-hidden">
<motion.span
className="flex w-max items-center gap-6 whitespace-nowrap"
animate={{ x: running ? [0, -distance] : 0 }}
transition={
running
? {
duration: Math.max(2.4, distance / 34),
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 2,
}
: ROW_REVEAL
}
>
<span ref={labelRef}>{children}</span>
{running ? <span aria-hidden="true">{children}</span> : null}
</motion.span>
</span>
);
}
interface ResourceRowProps {
row: FlatResource;
active: boolean;
expanded: boolean;
focused: boolean;
draggingId: string | null;
dropTarget: DropTarget | null;
menuOpen: boolean;
renaming: boolean;
onDragEnd: () => void;
onDragOver: (event: DragEvent<HTMLDivElement>, row: FlatResource) => void;
onDragStart: (event: DragEvent<HTMLDivElement>, id: string) => void;
onDrop: (event: DragEvent<HTMLDivElement>) => void;
onFocus: () => void;
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
onMenuOpenChange: (open: boolean) => void;
onRenameCancel: () => void;
onRenameCommit: (label: string) => void;
onRenameStart: () => void;
onSelect: () => void;
onToggle: () => void;
renderIcon?: (item: SidebarResource) => ReactNode;
renderMenu?: AISidebarProps["renderMenu"];
setRef: (node: HTMLDivElement | null) => void;
}
function ResourceRow({
row,
active,
expanded,
focused,
draggingId,
dropTarget,
menuOpen,
renaming,
onDragEnd,
onDragOver,
onDragStart,
onDrop,
onFocus,
onKeyDown,
onMenuOpenChange,
onRenameCancel,
onRenameCommit,
onRenameStart,
onSelect,
onToggle,
renderIcon,
renderMenu,
setRef,
}: ResourceRowProps) {
const reduce = useReducedMotion() ?? false;
const [hovered, setHovered] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const skipRenameBlurRef = useRef(false);
const draggedRef = useRef(false);
const [draft, setDraft] = useState(row.item.label);
const acceptsChildren = canContain(row.item);
const isDragging = draggingId === row.item.id;
const dropPosition = dropTarget?.id === row.item.id ? dropTarget.position : null;
useEffect(() => {
if (!renaming) return;
skipRenameBlurRef.current = false;
setDraft(row.item.label);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}, [renaming, row.item.label]);
const menu = renderMenu?.(row.item, {
close: () => onMenuOpenChange(false),
rename: () => {
onMenuOpenChange(false);
onRenameStart();
},
}) ?? (
<button
type="button"
onClick={() => {
onMenuOpenChange(false);
onRenameStart();
}}
className="flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left text-xs text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil aria-hidden="true" className="size-3.5" />
Rename
</button>
);
return (
<motion.div
ref={setRef}
layout="position"
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
role="treeitem"
aria-level={row.depth + 1}
aria-selected={acceptsChildren ? undefined : active}
aria-expanded={acceptsChildren ? expanded : undefined}
aria-disabled={row.item.disabled || undefined}
tabIndex={focused ? 0 : -1}
draggable={!row.item.disabled && !renaming}
data-menu-open={menuOpen || undefined}
data-drop={dropPosition ?? undefined}
data-dragging={isDragging || undefined}
onFocus={onFocus}
onKeyDown={onKeyDown}
onClick={(event) => {
if (
event.defaultPrevented ||
draggedRef.current ||
renaming ||
row.item.disabled
)
return;
if (acceptsChildren) onToggle();
else onSelect();
}}
onDoubleClick={(event) => {
if (acceptsChildren || row.item.disabled) return;
event.preventDefault();
onRenameStart();
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onDragStartCapture={(event) => {
draggedRef.current = true;
onDragStart(event, row.item.id);
}}
onDragEndCapture={() => {
onDragEnd();
requestAnimationFrame(() => {
draggedRef.current = false;
});
}}
onDragOver={(event) => onDragOver(event, row)}
onDrop={onDrop}
className={cn(
"group/resource relative flex min-h-9 min-w-0 cursor-pointer items-center gap-2.5 rounded-xl pr-3 text-sm outline-none",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"data-[menu-open=true]:bg-muted data-[menu-open=true]:text-foreground",
"data-[dragging=true]:opacity-40",
"data-[drop=inside]:bg-primary/10 data-[drop=inside]:ring-1 data-[drop=inside]:ring-primary/45",
"data-[drop=before]:before:absolute data-[drop=before]:before:-top-0.5 data-[drop=before]:before:right-2 data-[drop=before]:before:left-2 data-[drop=before]:before:h-0.5 data-[drop=before]:before:rounded-full data-[drop=before]:before:bg-primary",
"data-[drop=after]:after:absolute data-[drop=after]:after:-bottom-0.5 data-[drop=after]:after:right-2 data-[drop=after]:after:left-2 data-[drop=after]:after:h-0.5 data-[drop=after]:after:rounded-full data-[drop=after]:after:bg-primary",
!acceptsChildren && active && "bg-muted text-foreground",
row.item.disabled && "cursor-not-allowed opacity-45",
)}
style={{ paddingLeft: `${12 + row.depth * 16}px` }}
>
<span aria-hidden="true" className="grid size-5 shrink-0 place-items-center">
{renderIcon?.(row.item) ?? defaultIcon(row.item, expanded)}
</span>
{renaming ? (
<input
ref={inputRef}
value={draft}
aria-label={`Rename ${row.item.label}`}
onChange={(event) => setDraft(event.target.value)}
draggable={false}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onBlur={() => {
if (!skipRenameBlurRef.current) onRenameCommit(draft);
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
skipRenameBlurRef.current = true;
onRenameCommit(draft);
}
if (event.key === "Escape") {
skipRenameBlurRef.current = true;
onRenameCancel();
}
}}
className="mx-1 h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
) : (
<MarqueeLabel active={hovered || menuOpen}>{row.item.label}</MarqueeLabel>
)}
{!renaming && !row.item.disabled ? (
<MorphPopover
open={menuOpen}
onOpenChange={onMenuOpenChange}
>
<MorphPopoverTrigger>
<button
type="button"
draggable={false}
tabIndex={-1}
aria-label={`Actions for ${row.item.label}`}
onClick={(event) => event.stopPropagation()}
className="grid size-7 shrink-0 place-items-center rounded-lg opacity-0 outline-none transition-opacity hover:bg-foreground/5 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover/resource:opacity-100 group-data-[menu-open=true]/resource:opacity-100"
>
<MoreHorizontal aria-hidden="true" className="size-4" />
</button>
</MorphPopoverTrigger>
<MorphPopoverContent
side="bottom"
align="end"
sideOffset={8}
radius={12}
className="w-40 p-1.5"
>
<div data-sidebar-resource-menu={row.item.id}>{menu}</div>
</MorphPopoverContent>
</MorphPopover>
) : null}
</motion.div>
);
}
export function AISidebar({
items,
defaultItems = [],
onItemsChange,
onMove,
onMoveError,
onRename,
activeId,
defaultActiveId = null,
onActiveChange,
defaultExpandedIds = [],
renderIcon,
renderMenu,
ariaLabel = "Resources",
className,
}: AISidebarProps) {
const [internalItems, setInternalItems] = useState(items ?? defaultItems);
const [internalActiveId, setInternalActiveId] = useState(defaultActiveId);
const [expandedIds, setExpandedIds] = useState(
() => new Set(defaultExpandedIds),
);
const [focusedId, setFocusedId] = useState<string | null>(
activeId ?? defaultActiveId,
);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState("");
const rowRefs = useRef(new Map<string, HTMLDivElement>());
const movePendingRef = useRef(false);
const renderedItems = internalItems;
const selectedId = activeId ?? internalActiveId;
useEffect(() => {
if (items) setInternalItems(items);
}, [items]);
const flat = useMemo(
() => flattenResources(renderedItems, expandedIds),
[expandedIds, renderedItems],
);
useEffect(() => {
if (focusedId && flat.some((row) => row.item.id === focusedId)) return;
setFocusedId(flat[0]?.item.id ?? null);
}, [flat, focusedId]);
useEffect(() => {
if (!menuOpenId) return;
const frame = requestAnimationFrame(() => {
const menus = Array.from(
document.querySelectorAll<HTMLElement>("[data-sidebar-resource-menu]"),
);
menus
.find((menu) => menu.dataset.sidebarResourceMenu === menuOpenId)
?.querySelector<HTMLElement>("button, a[href]")
?.focus();
});
return () => cancelAnimationFrame(frame);
}, [menuOpenId]);
const updateItems = useCallback(
(next: SidebarResource[]) => {
setInternalItems(next);
onItemsChange?.(next);
},
[onItemsChange],
);
const performMove = useCallback(
async (move: SidebarResourceMove) => {
if (movePendingRef.current) {
setAnnouncement("Wait for the current move to finish.");
return;
}
const before = renderedItems;
const next = moveResource(before, move);
if (!next || next === before) return;
movePendingRef.current = true;
updateItems(next);
setDropTarget(null);
setDraggingId(null);
const moved = findResource(before, move.itemId);
const target = move.targetId ? findResource(before, move.targetId) : null;
setAnnouncement(
target
? `Moved ${moved?.label ?? "item"} ${move.position} ${target.label}.`
: `Moved ${moved?.label ?? "item"} to the top level.`,
);
try {
await onMove?.(move);
} catch (error) {
updateItems(before);
setAnnouncement(`Move failed. ${moved?.label ?? "Item"} was restored.`);
onMoveError?.(error, move);
} finally {
movePendingRef.current = false;
}
},
[onMove, onMoveError, renderedItems, updateItems],
);
const focusRow = useCallback((id: string) => {
setFocusedId(id);
requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
}, []);
const select = useCallback(
(id: string) => {
if (activeId === undefined) setInternalActiveId(id);
onActiveChange?.(id);
},
[activeId, onActiveChange],
);
const toggle = useCallback((id: string) => {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>, row: FlatResource) => {
const index = flat.findIndex(({ item }) => item.id === row.item.id);
const previous = flat[index - 1];
const next = flat[index + 1];
const moveModifier = event.altKey && event.shiftKey;
if (event.key === "ArrowDown" && !moveModifier && next) {
event.preventDefault();
focusRow(next.item.id);
return;
}
if (event.key === "ArrowUp" && !moveModifier && previous) {
event.preventDefault();
focusRow(previous.item.id);
return;
}
if (event.key === "Home" && flat[0]) {
event.preventDefault();
focusRow(flat[0].item.id);
return;
}
if (event.key === "End" && flat.at(-1)) {
event.preventDefault();
focusRow(flat.at(-1)?.item.id ?? row.item.id);
return;
}
if (row.item.disabled) {
if (event.key === "ArrowLeft" && row.parentId) {
event.preventDefault();
focusRow(row.parentId);
} else if (
moveModifier ||
["ArrowRight", "Enter", " ", "F2", "ContextMenu"].includes(
event.key,
) ||
(event.shiftKey && event.key === "F10")
) {
event.preventDefault();
}
return;
}
if (moveModifier && event.key === "ArrowUp" && previous) {
event.preventDefault();
void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "before" });
return;
}
if (moveModifier && event.key === "ArrowDown" && next) {
event.preventDefault();
void performMove({ itemId: row.item.id, targetId: next.item.id, position: "after" });
return;
}
if (moveModifier && event.key === "ArrowRight" && previous && canContain(previous.item)) {
event.preventDefault();
setExpandedIds((current) => new Set(current).add(previous.item.id));
void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "inside" });
return;
}
if (moveModifier && event.key === "ArrowLeft" && row.parentId) {
event.preventDefault();
void performMove({ itemId: row.item.id, targetId: row.parentId, position: "after" });
return;
}
if (event.key === "ArrowRight" && canContain(row.item)) {
event.preventDefault();
if (!expandedIds.has(row.item.id)) toggle(row.item.id);
else if (next?.parentId === row.item.id) focusRow(next.item.id);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
if (expandedIds.has(row.item.id)) toggle(row.item.id);
else if (row.parentId) focusRow(row.parentId);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (canContain(row.item)) toggle(row.item.id);
else select(row.item.id);
} else if (event.key === "F2") {
event.preventDefault();
setRenamingId(row.item.id);
} else if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
event.preventDefault();
setMenuOpenId(row.item.id);
}
},
[expandedIds, flat, focusRow, performMove, select, toggle],
);
return (
<>
<div
role="tree"
aria-label={ariaLabel}
aria-multiselectable="false"
onDragOver={(event) => {
if (!draggingId || event.target !== event.currentTarget) return;
event.preventDefault();
setDropTarget({ id: null, position: "after" });
}}
onDrop={(event) => {
event.preventDefault();
if (draggingId && dropTarget) {
void performMove({
itemId: draggingId,
targetId: dropTarget.id,
position: dropTarget.position,
});
}
}}
className={cn(
"relative flex min-w-0 flex-col gap-0.5 [overflow-anchor:none] group-data-[state=collapsed]/sidebar:hidden",
draggingId && "select-none pb-9",
className,
)}
>
<AnimatePresence initial={false}>
{flat.map((row) => (
<ResourceRow
key={row.item.id}
row={row}
active={selectedId === row.item.id}
expanded={expandedIds.has(row.item.id)}
focused={focusedId === row.item.id}
draggingId={draggingId}
dropTarget={dropTarget}
menuOpen={menuOpenId === row.item.id}
renaming={renamingId === row.item.id}
onFocus={() => setFocusedId(row.item.id)}
onSelect={() => select(row.item.id)}
onToggle={() => toggle(row.item.id)}
onKeyDown={(event) => handleKeyDown(event, row)}
onRenameStart={() => setRenamingId(row.item.id)}
onRenameCancel={() => setRenamingId(null)}
onRenameCommit={(label) => {
const trimmed = label.trim();
setRenamingId(null);
if (!trimmed || trimmed === row.item.label) return;
const before = renderedItems;
updateItems(renameResource(before, row.item.id, trimmed));
void Promise.resolve(onRename?.(row.item, trimmed)).catch(() => {
updateItems(before);
setAnnouncement(`Rename failed. ${row.item.label} was restored.`);
});
}}
onMenuOpenChange={(open) => {
setMenuOpenId(open ? row.item.id : null);
if (!open) focusRow(row.item.id);
}}
onDragStart={(event, id) => {
setDraggingId(id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", id);
}}
onDragEnd={() => {
setDraggingId(null);
setDropTarget(null);
}}
onDragOver={(event, targetRow) => {
if (!draggingId || draggingId === targetRow.item.id) return;
const source = findResource(renderedItems, draggingId);
if (source && containsResource(source, targetRow.item.id)) return;
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
const position =
!targetRow.item.disabled &&
canContain(targetRow.item) &&
ratio >= 0.25 &&
ratio <= 0.75
? "inside"
: ratio < 0.5
? "before"
: "after";
setDropTarget({ id: targetRow.item.id, position });
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
if (draggingId && dropTarget) {
void performMove({
itemId: draggingId,
targetId: dropTarget.id,
position: dropTarget.position,
});
}
}}
renderIcon={renderIcon}
renderMenu={renderMenu}
setRef={(node) => {
if (node) rowRefs.current.set(row.item.id, node);
else rowRefs.current.delete(row.item.id);
}}
/>
))}
</AnimatePresence>
{draggingId ? (
<div
aria-hidden="true"
data-active={dropTarget?.id === null || undefined}
className="absolute inset-x-1 bottom-0 flex h-8 items-center justify-center rounded-lg border border-dashed border-border text-[10px] text-muted-foreground data-[active=true]:border-primary/50 data-[active=true]:bg-primary/10 data-[active=true]:text-foreground"
>
Move to top level
</div>
) : null}
</div>
<span className="sr-only" aria-live="polite">
{announcement}
</span>
</>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
triggerRef: React.MutableRefObject<HTMLElement | null>;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
rootRef.current &&
!rootRef.current.contains(target) &&
!contentRef.current?.contains(target)
)
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, setOpen]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef,
contentRef,
}),
[open, setOpen, toggle, baseId],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={rootRef} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, (node: HTMLElement | null) => {
ctx.triggerRef.current = node;
}),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
"use client";
import { ChevronRight } from "lucide-react";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
type ButtonHTMLAttributes,
type CSSProperties,
createContext,
forwardRef,
type HTMLAttributes,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import { SharedLayoutBg } from "@/components/motion/shared-layout-bg";
import {
EASE_DRAWER,
EASE_OUT,
SPRING_LAYOUT,
SPRING_PRESS,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
type SidebarState = "expanded" | "collapsed";
type SidebarSide = "left" | "right";
type SidebarVariant = "sidebar" | "floating" | "inset";
type SidebarCollapsible = "offcanvas" | "icon" | "none";
const MOBILE_QUERY = "(max-width: 767px)";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const PANEL_TRANSITION = {
duration: 0.36,
ease: EASE_DRAWER,
} as const;
// The desktop rail settles at a hard zero-width boundary. Keep the spring
// critically damped so it cannot overshoot, pause against that boundary, and
// then snap back during the final frame.
const SIDEBAR_MORPH_TRANSITION = {
type: "spring",
stiffness: 380,
damping: 35,
mass: 0.75,
} as const;
const LABEL_ENTER_TRANSITION = {
duration: 0.2,
delay: 0.08,
ease: EASE_OUT,
} as const;
const LABEL_EXIT_TRANSITION = {
duration: 0.12,
ease: EASE_OUT,
} as const;
const SUBMENU_TRANSITION = {
duration: 0.18,
ease: EASE_OUT,
} as const;
const SUBMENU_VARIANTS: Variants = {
closed: {
opacity: 0,
clipPath: "inset(0 0 100% 0 round 8px)",
transition: {
duration: 0.14,
ease: EASE_OUT,
staggerChildren: 0.025,
staggerDirection: -1,
},
},
open: {
opacity: 1,
clipPath: "inset(0 0 0% 0 round 8px)",
transition: {
duration: 0.2,
delayChildren: 0.035,
ease: EASE_OUT,
staggerChildren: 0.045,
},
},
};
const SUBMENU_ITEM_VARIANTS: Variants = {
closed: {
opacity: 0,
y: -6,
filter: "blur(3px)",
},
open: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: SUBMENU_TRANSITION,
},
};
const REDUCED_TRANSITION = {
duration: 0.16,
ease: EASE_OUT,
} as const;
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function subscribeToMobileQuery(callback: () => void) {
const query = window.matchMedia(MOBILE_QUERY);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
function getMobileSnapshot() {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerMobileSnapshot() {
return false;
}
function useIsMobile() {
return useSyncExternalStore(
subscribeToMobileQuery,
getMobileSnapshot,
getServerMobileSnapshot,
);
}
interface AnimatedSidebarContextValue {
isMobile: boolean;
layoutId: string;
open: boolean;
openMobile: boolean;
reduce: boolean;
setOpen: (open: boolean) => void;
setOpenMobile: (open: boolean) => void;
state: SidebarState;
toggleSidebar: () => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}
const AnimatedSidebarContext =
createContext<AnimatedSidebarContextValue | null>(null);
interface AnimatedSidebarPanelContextValue {
collapsed: boolean;
collapsible: SidebarCollapsible;
side: SidebarSide;
}
const AnimatedSidebarPanelContext =
createContext<AnimatedSidebarPanelContextValue | null>(null);
export function useAnimatedSidebar() {
const context = useContext(AnimatedSidebarContext);
if (!context) {
throw new Error(
"useAnimatedSidebar must be used inside AnimatedSidebarProvider.",
);
}
return context;
}
function useAnimatedSidebarPanel() {
const context = useContext(AnimatedSidebarPanelContext);
if (!context) {
throw new Error(
"Animated Sidebar parts must be used inside AnimatedSidebar.",
);
}
return context;
}
type SidebarProviderStyle = CSSProperties & {
"--sidebar-width"?: string;
"--sidebar-width-icon"?: string;
"--sidebar-width-mobile"?: string;
};
export interface AnimatedSidebarProviderProps
extends HTMLAttributes<HTMLDivElement> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
openMobile?: boolean;
defaultOpenMobile?: boolean;
onOpenMobileChange?: (open: boolean) => void;
style?: SidebarProviderStyle;
}
export function AnimatedSidebarProvider({
children,
open,
defaultOpen = true,
onOpenChange,
openMobile,
defaultOpenMobile = false,
onOpenMobileChange,
className,
style,
...props
}: AnimatedSidebarProviderProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [internalOpenMobile, setInternalOpenMobile] =
useState(defaultOpenMobile);
const isMobile = useIsMobile();
const reduce = useReducedMotion() ?? false;
const generatedId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const desktopOpen = open ?? internalOpen;
const mobileOpen = openMobile ?? internalOpenMobile;
const setOpen = useCallback(
(nextOpen: boolean) => {
if (open === undefined) setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[onOpenChange, open],
);
const setOpenMobile = useCallback(
(nextOpen: boolean) => {
if (openMobile === undefined) setInternalOpenMobile(nextOpen);
onOpenMobileChange?.(nextOpen);
},
[onOpenMobileChange, openMobile],
);
const toggleSidebar = useCallback(() => {
if (isMobile) setOpenMobile(!mobileOpen);
else setOpen(!desktopOpen);
}, [desktopOpen, isMobile, mobileOpen, setOpen, setOpenMobile]);
useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
if (
event.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, [toggleSidebar]);
return (
<AnimatedSidebarContext.Provider
value={{
isMobile,
layoutId: `${generatedId}-active`,
open: desktopOpen,
openMobile: mobileOpen,
reduce,
setOpen,
setOpenMobile,
state: desktopOpen ? "expanded" : "collapsed",
toggleSidebar,
triggerRef,
}}
>
<div
{...props}
data-slot="sidebar-wrapper"
data-state={desktopOpen ? "expanded" : "collapsed"}
style={{
"--sidebar-width": "16rem",
"--sidebar-width-icon": "4.25rem",
"--sidebar-width-mobile": "18rem",
...style,
}}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full min-w-0",
className,
)}
>
{children}
</div>
</AnimatedSidebarContext.Provider>
);
}
function MobileSidebar({
ariaLabel,
children,
className,
side,
}: {
ariaLabel: string;
children: ReactNode;
className?: string;
side: SidebarSide;
}) {
const context = useAnimatedSidebar();
const panelRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!context.openMobile) return;
const body = document.body;
const scrollY = window.scrollY;
const previousBodyStyles = {
left: body.style.left,
overflow: body.style.overflow,
position: body.style.position,
right: body.style.right,
top: body.style.top,
};
body.style.position = "fixed";
body.style.top = `-${scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.overflow = "hidden";
const focusFrame = requestAnimationFrame(() => {
const firstFocusable =
panelRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
(firstFocusable ?? panelRef.current)?.focus({ preventScroll: true });
});
return () => {
cancelAnimationFrame(focusFrame);
body.style.position = previousBodyStyles.position;
body.style.top = previousBodyStyles.top;
body.style.left = previousBodyStyles.left;
body.style.right = previousBodyStyles.right;
body.style.overflow = previousBodyStyles.overflow;
window.scrollTo(0, scrollY);
context.triggerRef.current?.focus({ preventScroll: true });
};
}, [context.openMobile, context.triggerRef]);
if (!mounted) return null;
return createPortal(
<div
className={cn(
"pointer-events-none fixed inset-0 z-50 md:hidden",
context.openMobile ? "visible" : "invisible",
)}
>
<motion.button
type="button"
aria-label="Close sidebar"
tabIndex={context.openMobile ? 0 : -1}
initial={false}
animate={{ opacity: context.openMobile ? 1 : 0 }}
transition={
context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
}
onClick={() => context.setOpenMobile(false)}
className={cn(
"absolute inset-0 bg-black/40",
context.openMobile
? "pointer-events-auto"
: "pointer-events-none",
)}
/>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-hidden={!context.openMobile}
inert={!context.openMobile}
tabIndex={-1}
data-mobile="true"
data-state={context.openMobile ? "expanded" : "collapsed"}
data-side={side}
initial={false}
animate={{
opacity: context.reduce
? context.openMobile
? 1
: 0
: 1,
x: context.reduce
? 0
: context.openMobile
? "0%"
: side === "left"
? "-100%"
: "100%",
}}
transition={
context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
context.setOpenMobile(false);
return;
}
if (event.key !== "Tab") return;
const focusable = panelRef.current
? Array.from(
panelRef.current.querySelectorAll<HTMLElement>(
FOCUSABLE_SELECTOR,
),
)
: [];
if (focusable.length === 0) {
event.preventDefault();
panelRef.current?.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
className={cn(
"pointer-events-auto absolute inset-y-0 flex h-dvh w-(--sidebar-width-mobile) max-w-[88vw] flex-col overflow-hidden",
"border-border bg-background shadow-2xl will-change-transform",
side === "left" ? "left-0 border-r" : "right-0 border-l",
!context.openMobile && "pointer-events-none",
className,
)}
>
<AnimatedSidebarPanelContext.Provider
value={{ collapsed: false, collapsible: "none", side }}
>
{children}
</AnimatedSidebarPanelContext.Provider>
</motion.div>
</div>,
document.body,
);
}
export interface AnimatedSidebarProps
extends Omit<HTMLMotionProps<"aside">, "children"> {
children?: ReactNode;
side?: SidebarSide;
variant?: SidebarVariant;
collapsible?: SidebarCollapsible;
ariaLabel?: string;
panelClassName?: string;
}
export const AnimatedSidebar = forwardRef<HTMLElement, AnimatedSidebarProps>(
function AnimatedSidebar(
{
side = "left",
variant = "sidebar",
collapsible = "icon",
ariaLabel = "Sidebar",
children,
className,
panelClassName,
style,
...props
},
forwardedRef,
) {
const context = useAnimatedSidebar();
const collapsed = collapsible !== "none" && !context.open;
const offcanvas = collapsed && collapsible === "offcanvas";
const width = offcanvas
? "0px"
: collapsed
? "var(--sidebar-width-icon)"
: "var(--sidebar-width)";
if (context.isMobile) {
return (
<MobileSidebar
ariaLabel={ariaLabel}
className={className}
side={side}
>
{children}
</MobileSidebar>
);
}
return (
<motion.aside
{...props}
ref={forwardedRef}
initial={false}
aria-label={ariaLabel}
data-slot="sidebar"
data-state={collapsed ? "collapsed" : "expanded"}
data-collapsible={collapsible}
data-variant={variant}
data-side={side}
animate={{ width }}
transition={
context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION
}
style={style}
className={cn(
"group/sidebar relative hidden h-auto shrink-0 md:block will-change-[width]",
"peer",
side === "right" && "order-last",
className,
)}
>
<motion.div
initial={false}
animate={{
opacity: offcanvas ? 0 : 1,
x: offcanvas ? (side === "left" ? "-100%" : "100%") : "0%",
}}
transition={
context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION
}
className={cn(
"sticky top-0 flex h-svh w-full flex-col overflow-hidden bg-background",
collapsible === "offcanvas" && "w-[var(--sidebar-width)]",
variant === "sidebar" &&
(side === "left" ? "border-border border-r" : "border-border border-l"),
variant === "floating" &&
"m-2 h-[calc(100svh-1rem)] rounded-2xl border border-border shadow-sm",
variant === "inset" && "m-2 h-[calc(100svh-1rem)] rounded-2xl",
panelClassName,
)}
>
<AnimatedSidebarPanelContext.Provider
value={{ collapsed, collapsible, side }}
>
{children}
</AnimatedSidebarPanelContext.Provider>
</motion.div>
</motion.aside>
);
},
);
export interface AnimatedSidebarTriggerProps
extends ButtonHTMLAttributes<HTMLButtonElement> {}
export const AnimatedSidebarTrigger = forwardRef<
HTMLButtonElement,
AnimatedSidebarTriggerProps
>(function AnimatedSidebarTrigger(
{ className, onClick, type = "button", ...props },
forwardedRef,
) {
const context = useAnimatedSidebar();
const expanded = context.isMobile ? context.openMobile : context.open;
return (
<button
{...props}
ref={(node) => {
context.triggerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
type={type}
aria-label={props["aria-label"] ?? "Toggle sidebar"}
aria-expanded={expanded}
data-slot="sidebar-trigger"
data-state={expanded ? "expanded" : "collapsed"}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) context.toggleSidebar();
}}
className={cn(
"inline-flex size-10 shrink-0 items-center justify-center rounded-xl outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
/>
);
});
export interface AnimatedSidebarCloseProps
extends ButtonHTMLAttributes<HTMLButtonElement> {}
export const AnimatedSidebarClose = forwardRef<
HTMLButtonElement,
AnimatedSidebarCloseProps
>(function AnimatedSidebarClose(
{ className, onClick, type = "button", ...props },
forwardedRef,
) {
const context = useAnimatedSidebar();
return (
<button
{...props}
ref={forwardedRef}
type={type}
aria-label={props["aria-label"] ?? "Close sidebar"}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.isMobile) context.setOpenMobile(false);
else context.setOpen(false);
}}
className={cn(
"inline-flex size-10 shrink-0 items-center justify-center rounded-xl outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
/>
);
});
export interface AnimatedSidebarRailProps
extends ButtonHTMLAttributes<HTMLButtonElement> {}
export const AnimatedSidebarRail = forwardRef<
HTMLButtonElement,
AnimatedSidebarRailProps
>(function AnimatedSidebarRail(
{ className, onClick, type = "button", ...props },
forwardedRef,
) {
const context = useAnimatedSidebar();
const panel = useAnimatedSidebarPanel();
return (
<button
{...props}
ref={forwardedRef}
type={type}
data-side={panel.side}
aria-label={props["aria-label"] ?? "Toggle sidebar"}
title="Toggle sidebar"
tabIndex={-1}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) context.toggleSidebar();
}}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-none md:block",
"after:absolute after:inset-y-0 after:left-1/2 after:w-px after:bg-transparent after:transition-colors hover:after:bg-border",
"data-[side=right]:right-0 data-[side=right]:translate-x-1/2 data-[side=left]:left-full",
className,
)}
/>
);
});
export interface AnimatedSidebarInsetProps
extends HTMLMotionProps<"main"> {}
export const AnimatedSidebarInset = forwardRef<
HTMLElement,
AnimatedSidebarInsetProps
>(function AnimatedSidebarInset({ className, ...props }, forwardedRef) {
return (
<motion.main
{...props}
ref={forwardedRef}
data-slot="sidebar-inset"
className={cn(
"relative flex min-h-svh min-w-0 flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm",
className,
)}
/>
);
});
export const AnimatedSidebarHeader = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarHeader({ className, ...props }, forwardedRef) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-header"
className={cn("flex shrink-0 flex-col gap-2 p-3", className)}
/>
);
});
export const AnimatedSidebarContent = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarContent({ className, ...props }, forwardedRef) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden overscroll-contain px-2 py-2",
className,
)}
/>
);
});
export const AnimatedSidebarFooter = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarFooter({ className, ...props }, forwardedRef) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-footer"
className={cn(
"flex shrink-0 flex-col gap-2 border-border border-t p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]",
className,
)}
/>
);
});
export const AnimatedSidebarGroup = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarGroup({ className, ...props }, forwardedRef) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-group"
className={cn("flex w-full min-w-0 flex-col px-1 py-1.5", className)}
/>
);
});
export const AnimatedSidebarGroupLabel = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarGroupLabel(
{ children, className, ...props },
forwardedRef,
) {
const { collapsed } = useAnimatedSidebarPanel();
return (
<div
{...props}
ref={forwardedRef}
aria-hidden={collapsed}
data-slot="sidebar-group-label"
className={cn(
"mb-1 h-7 overflow-hidden px-2 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground transition-opacity",
collapsed ? "opacity-0" : "opacity-100",
className,
)}
>
{children}
</div>
);
});
export const AnimatedSidebarGroupContent = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function AnimatedSidebarGroupContent(
{ className, ...props },
forwardedRef,
) {
return (
<div
{...props}
ref={forwardedRef}
data-slot="sidebar-group-content"
className={cn("w-full min-w-0", className)}
/>
);
});
export const AnimatedSidebarMenu = forwardRef<
HTMLUListElement,
HTMLAttributes<HTMLUListElement>
>(function AnimatedSidebarMenu(
{ children, className, ...props },
forwardedRef,
) {
return (
<SharedLayoutBg
{...props}
ref={forwardedRef as React.Ref<HTMLElement>}
as="ul"
inset={0}
pillClassName="rounded-xl bg-muted/70"
pillContainerClassName="inset-y-auto top-0 h-9"
data-slot="sidebar-menu"
className={cn("flex w-full min-w-0 list-none flex-col gap-0.5", className)}
>
{children}
</SharedLayoutBg>
);
});
export const AnimatedSidebarMenuItem = forwardRef<
HTMLLIElement,
HTMLMotionProps<"li">
>(function AnimatedSidebarMenuItem({ className, ...props }, forwardedRef) {
return (
<motion.li
{...props}
ref={forwardedRef}
layout="position"
transition={SPRING_LAYOUT}
data-slot="sidebar-menu-item"
className={cn("relative", className)}
/>
);
});
export interface AnimatedSidebarMenuSubProps
extends Omit<HTMLMotionProps<"ul">, "children"> {
open: boolean;
children?: ReactNode;
}
export const AnimatedSidebarMenuSub = forwardRef<
HTMLUListElement,
AnimatedSidebarMenuSubProps
>(function AnimatedSidebarMenuSub(
{ open, children, className, ...props },
forwardedRef,
) {
const context = useAnimatedSidebar();
const panel = useAnimatedSidebarPanel();
return (
<AnimatePresence initial={false} mode="popLayout">
{open && !panel.collapsed ? (
<motion.ul
{...props}
ref={forwardedRef}
key="sidebar-submenu"
variants={context.reduce ? undefined : SUBMENU_VARIANTS}
initial={context.reduce ? false : "closed"}
animate={context.reduce ? { opacity: 1 } : "open"}
exit={context.reduce ? { opacity: 0 } : "closed"}
transition={context.reduce ? { duration: 0.12 } : undefined}
data-slot="sidebar-menu-sub"
className={cn(
"relative mt-1 ml-5 flex min-w-0 flex-col gap-0.5 border-border border-l pl-3",
className,
)}
>
{children}
</motion.ul>
) : null}
</AnimatePresence>
);
});
export const AnimatedSidebarMenuSubItem = forwardRef<
HTMLLIElement,
HTMLMotionProps<"li">
>(function AnimatedSidebarMenuSubItem(
{ className, ...props },
forwardedRef,
) {
return (
<motion.li
{...props}
ref={forwardedRef}
variants={SUBMENU_ITEM_VARIANTS}
data-slot="sidebar-menu-sub-item"
className={cn("relative min-w-0", className)}
/>
);
});
export interface AnimatedSidebarMenuSubButtonProps {
children: ReactNode;
icon?: ReactNode;
href?: string;
isActive?: boolean;
disabled?: boolean;
closeOnSelect?: boolean;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string;
onSelect?: () => void;
className?: string;
}
export function AnimatedSidebarMenuSubButton({
children,
icon,
href,
isActive = false,
disabled = false,
closeOnSelect = true,
target,
rel,
onSelect,
className,
}: AnimatedSidebarMenuSubButtonProps) {
const context = useAnimatedSidebar();
const select = (
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
) => {
if (disabled) {
event.preventDefault();
return;
}
onSelect?.();
if (context.isMobile && closeOnSelect) context.setOpenMobile(false);
};
const content = (
<>
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center"
>
{icon ?? <span className="size-1 rounded-full bg-current" />}
</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</>
);
const interactiveClassName = cn(
"flex min-h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-xs outline-none",
"text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground",
"focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring",
isActive && "bg-muted/70 text-foreground",
disabled && "cursor-not-allowed opacity-40",
className,
);
return href ? (
<motion.a
href={href}
target={target}
rel={
rel ??
(target === "_blank" ? "noreferrer noopener" : undefined)
}
aria-current={isActive ? "page" : undefined}
aria-disabled={disabled || undefined}
tabIndex={disabled ? -1 : undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.a>
) : (
<motion.button
type="button"
disabled={disabled}
aria-current={isActive ? "page" : undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.button>
);
}
export interface AnimatedSidebarMenuButtonProps {
children: ReactNode;
icon?: ReactNode;
badge?: ReactNode;
href?: string;
isActive?: boolean;
ariaExpanded?: boolean;
disabled?: boolean;
closeOnSelect?: boolean;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string;
onSelect?: () => void;
className?: string;
}
export function AnimatedSidebarMenuButton({
children,
icon,
badge,
href,
isActive = false,
ariaExpanded,
disabled = false,
closeOnSelect,
target,
rel,
onSelect,
className,
}: AnimatedSidebarMenuButtonProps) {
const context = useAnimatedSidebar();
const panel = useAnimatedSidebarPanel();
const textLabel = typeof children === "string" ? children : undefined;
const select = (
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
) => {
if (disabled) {
event.preventDefault();
return;
}
onSelect?.();
const shouldCloseOnSelect =
closeOnSelect ?? ariaExpanded === undefined;
if (context.isMobile && shouldCloseOnSelect) {
context.setOpenMobile(false);
}
};
const content = (
<>
{isActive ? (
<motion.span
layoutId={context.layoutId}
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
className="absolute inset-0 rounded-xl bg-muted"
/>
) : null}
{icon ? (
<span
aria-hidden="true"
className="relative z-10 grid size-5 shrink-0 place-items-center"
>
{icon}
</span>
) : null}
<motion.span
initial={false}
animate={{
opacity: panel.collapsed ? 0 : 1,
x: panel.collapsed ? -4 : 0,
}}
transition={
context.reduce
? REDUCED_TRANSITION
: panel.collapsed
? LABEL_EXIT_TRANSITION
: LABEL_ENTER_TRANSITION
}
aria-hidden={panel.collapsed}
className={cn(
"relative z-10 min-w-0 flex-1 truncate",
panel.collapsed && "pointer-events-none",
)}
>
{children}
</motion.span>
{badge && !panel.collapsed ? (
<span className="relative z-10 shrink-0 text-xs text-muted-foreground">
{badge}
</span>
) : null}
{ariaExpanded !== undefined ? (
<motion.span
aria-hidden="true"
initial={false}
animate={{
opacity: panel.collapsed ? 0 : 1,
rotate: ariaExpanded ? 90 : 0,
x: panel.collapsed ? 4 : 0,
}}
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
className="relative z-10 grid size-4 shrink-0 place-items-center text-muted-foreground"
>
<ChevronRight className="size-3.5" />
</motion.span>
) : null}
</>
);
const interactiveClassName = cn(
"relative flex min-h-9 w-full min-w-0 items-center gap-2.5 overflow-hidden rounded-xl px-3 text-left text-sm font-medium outline-none",
"text-muted-foreground transition-colors hover:text-foreground",
"focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring",
isActive && "text-foreground",
disabled && "cursor-not-allowed opacity-40",
className,
);
return href ? (
<motion.a
href={href}
target={target}
rel={
rel ??
(target === "_blank" ? "noreferrer noopener" : undefined)
}
aria-current={isActive ? "page" : undefined}
aria-expanded={ariaExpanded}
aria-disabled={disabled || undefined}
aria-label={panel.collapsed ? textLabel : undefined}
title={panel.collapsed ? textLabel : undefined}
tabIndex={disabled ? -1 : undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.a>
) : (
<motion.button
type="button"
disabled={disabled}
aria-current={isActive ? "page" : undefined}
aria-expanded={ariaExpanded}
aria-label={panel.collapsed ? textLabel : undefined}
title={panel.collapsed ? textLabel : undefined}
onClick={select}
whileTap={context.reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={interactiveClassName}
>
{content}
</motion.button>
);
}
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
Children,
cloneElement,
forwardRef,
type HTMLAttributes,
isValidElement,
type MouseEvent,
type ReactElement,
type ReactNode,
type Ref,
useId,
useState,
} from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SharedLayoutBgProps
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
children: ReactNode;
/** Semantic container used for the children. */
as?: "div" | "ul";
/** Tailwind class applied to the moving pill. Defaults to a subtle foreground tint. */
pillClassName?: string;
/** Horizontal inset of the pill relative to each row (px). Default 20. */
inset?: number;
/** Optional positioning override for the pill wrapper inside each item. */
pillContainerClassName?: string;
}
const variants: Variants = {
initial: { opacity: 0, filter: "blur(6px)" },
animate: { opacity: 1, filter: "blur(0px)" },
exit: (isActive: boolean) =>
!isActive ? { opacity: 0, filter: "blur(6px)" } : {},
};
const reducedVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: (isActive: boolean) => (!isActive ? { opacity: 0 } : {}),
};
export const SharedLayoutBg = forwardRef<HTMLElement, SharedLayoutBgProps>(
function SharedLayoutBg(
{
children,
as = "div",
className,
onMouseLeave,
pillClassName,
pillContainerClassName,
inset = 20,
...props
},
forwardedRef,
) {
const [activeId, setActiveId] = useState<string | null>(null);
const uid = useId();
const reduce = useReducedMotion();
const renderedChildren = Children.toArray(children)
.filter(isValidElement)
.map((child, index) => {
const el = child as ReactElement<{
className?: string;
onMouseEnter?: () => void;
children?: ReactNode;
}>;
const childKey = el.key ? String(el.key) : `item-${index}`;
return cloneElement(
el,
{
key: childKey,
className: cn("relative", el.props.className),
onMouseEnter: () => {
el.props.onMouseEnter?.();
setActiveId(childKey);
},
},
<>
<AnimatePresence custom={activeId !== null}>
{activeId !== null ? (
<motion.div
variants={reduce ? reducedVariants : variants}
initial="initial"
animate="animate"
exit="exit"
custom={activeId !== null}
className={cn(
"pointer-events-none absolute inset-y-0",
pillContainerClassName,
)}
style={{ left: -inset, right: -inset }}
>
{activeId === childKey ? (
<motion.div
layoutId={`shared-bg-${uid}`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"pointer-events-none h-full w-full rounded-2xl bg-primary/[0.06]",
pillClassName,
)}
/>
) : null}
</motion.div>
) : null}
</AnimatePresence>
<div className="relative z-10">{el.props.children}</div>
</>,
);
});
const handleMouseLeave = (event: MouseEvent<HTMLElement>) => {
setActiveId(null);
onMouseLeave?.(event);
};
// layoutRoot scopes the pill's layout projection to this list, so fixed or
// scrolled ancestors can't smear scroll offsets into its movement.
return as === "ul" ? (
<motion.ul
{...(props as HTMLMotionProps<"ul">)}
ref={forwardedRef as Ref<HTMLUListElement>}
layoutRoot
onMouseLeave={handleMouseLeave}
className={cn("flex w-full flex-col", className)}
>
{renderedChildren}
</motion.ul>
) : (
<motion.div
{...(props as HTMLMotionProps<"div">)}
ref={forwardedRef as Ref<HTMLDivElement>}
layoutRoot
onMouseLeave={handleMouseLeave}
className={cn("flex w-full flex-col", className)}
>
{renderedChildren}
</motion.div>
);
},
);
API Reference
items?SidebarResource[]—defaultItems?SidebarResource[][]onItemsChange?((items: SidebarResource[]) => void)—onMove?((move: SidebarResourceMove) => void | Promise<void>)Reject the promise to roll the optimistic move back.
—onMoveError?((error: unknown, move: SidebarResourceMove) => void)—onRename?((item: SidebarResource, label: string) => void | Promise<void>)—activeId?string | null—defaultActiveId?string | nullnullonActiveChange?((id: string) => void)—defaultExpandedIds?string[][]renderIcon?((item: SidebarResource) => ReactNode)—renderMenu?((item: SidebarResource, controls: SidebarResourceMenuControls) => ReactNode)—ariaLabel?stringResourcesclassName?string—Composition
Place the resource tree inside Animated Sidebar content and keep the selected resource surface in the sibling inset.
AnimatedSidebarProvider
├── AnimatedSidebar
│ ├── AnimatedSidebarContent
│ │ └── AISidebar
│ │ ├── Folder or project
│ │ └── File or bookmark
│ └── AnimatedSidebarRail
└── AnimatedSidebarInsetNote: Message Scroller can fill the inset with the selected conversation. Prompt Input can anchor the composer beneath active resource content.
How it works
An AI sidebar organizes changing workspace resources rather than only routing between fixed destinations. Folder and project rows disclose children, while files and bookmarks become the active resource and can move or rename without destabilizing the surrounding workspace.
Updated