Sidebar Nav

Controls & Canvas

Collapsible workspace and chat navigation with gliding hover states.

Interactive Live Preview
components/ui/sidebar-nav.tsxTypeScript · React 19
1"use client";
2
3import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
4import { createPortal } from "react-dom";
5import { IconArrowBoxLeft } from "@/components/icons";
6import { IconCheckmark1Small } from "@/components/icons";
7import { IconChevronDownSmall } from "@/components/icons";
8import { IconCrossSmall } from "@/components/icons";
9import { IconEditBig } from "@/components/icons";
10import { IconHome } from "@/components/icons";
11import { IconMagnifyingGlass } from "@/components/icons";
12import { IconPlusMedium } from "@/components/icons";
13import { IconPopsicle2 } from "@/components/icons";
14import { IconSettingsGear1 } from "@/components/icons";
15import { IconSidebarLeftArrow } from "@/components/icons";
16import { IconUserAdd } from "@/components/icons";
17import GlideMenu from "@/components/primitives/GlideMenu";
18
19/* ─────────────────────────────────────────────────────────
20 * SIDEBAR NAV
21 * Shared by the design-system preview and the harness shell:
22 * compact workspace switcher, primary navigation, searchable
23 * chat history, and a collapse that preserves icon alignment.
24 * ───────────────────────────────────────────────────────── */
25
26const WORKSPACE = { key: "creamery", name: "Creamery Ops", monogram: "C" };
27
28const NAV_ITEMS = [
29 { key: "home", label: "Home", icon: <IconHome size={18} /> },
30 { key: "invite", label: "Invite users", icon: <IconUserAdd size={18} />, count: "3/10" },
31];
32
33export type SidebarRecent = {
34 id: string;
35 label: string;
36 prompt?: string;
37};
38
39const DEFAULT_RECENTS: SidebarRecent[] = [
40 { id: "suppliers", label: "Supplier records" },
41 { id: "todos", label: "Urgent to-dos this morning" },
42 { id: "flavor", label: "Flavor page ticket" },
43 { id: "workload", label: "Workload summary" },
44 { id: "offboarding", label: "Off-board a supplier" },
45 { id: "restock", label: "Batch restock function" },
46 { id: "edits", label: "Propose flavor edits" },
47 { id: "subway", label: "Subway surfing" },
48];
49
50type SidebarNavProps = {
51 activeTitle?: string | null;
52 className?: string;
53 fill?: boolean;
54 onNewChat?: () => void;
55 onPick?: (id: string, label: string, prompt?: string) => void;
56 /** controlled primary-nav selection (e.g. "home" | "invite") */
57 activeNav?: string;
58 onNavigate?: (key: string) => void;
59 /** footer call-to-action — defaults to the demo "Upgrade" button */
60 footerLabel?: string;
61 footerIcon?: ReactNode;
62 onFooterClick?: () => void;
63 recents?: SidebarRecent[];
64 variant?: string;
65};
66
67const SIDEBAR_MOTION = {
68 expandedWidth: 224,
69 collapsedWidth: 52,
70 duration: 280,
71 copyDuration: 180,
72 copyOffset: 8,
73 easing: "cubic-bezier(0.16, 1, 0.3, 1)",
74};
75
76/* ─────────────────────────────────────────────────────────
77 * CHAT SEARCH STORYBOARD
78 *
79 * 0ms search is triggered; Chats label begins fading
80 * 0ms field grows right → left from the search control
81 * 180ms field fills the row; cursor is focused and ready
82 * ───────────────────────────────────────────────────────── */
83const CHAT_SEARCH_MOTION = {
84 duration: 180,
85 closedWidth: 28,
86 easing: "cubic-bezier(0.16, 1, 0.3, 1)",
87};
88
89function GlideGroup({ children }: { children: ReactNode }) {
90 return (
91 <GlideMenu
92 rowSelector="[data-row]"
93 highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2"
94 className="group/glide flex flex-col gap-px"
95 >
96 {children}
97 </GlideMenu>
98 );
99}
100
101function RailButton({
102 icon,
103 label,
104 active = false,
105 count,
106 onClick,
107}: {
108 icon: ReactNode;
109 label: string;
110 active?: boolean;
111 count?: string;
112 onClick?: () => void;
113}) {
114 return (
115 <button
116 data-row
117 type="button"
118 onClick={onClick}
119 className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left
120 transition-[width,background-color,color,transform] duration-150 active:scale-[0.98]
121 ${active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""}`}
122 >
123 <span className={`flex size-5 shrink-0 items-center justify-center ${active ? "text-ink" : "text-ink-2"}`}>
124 {icon}
125 </span>
126 <span className={`sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
127 {label}
128 </span>
129 {count && (
130 <span className="sidebar-copy mr-2 shrink-0 text-[12px] font-medium tabular-nums text-ink-3">
131 {count}
132 </span>
133 )}
134 </button>
135 );
136}
137
138function WorkspaceMenu({
139 position,
140 onClose,
141}: {
142 position: { top: number; left: number };
143 onClose: () => void;
144}) {
145 return createPortal(
146 <div
147 data-workspace-menu
148 className="fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay"
149 style={{
150 top: position.top,
151 left: position.left,
152 animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both",
153 transformOrigin: "top left",
154 }}
155 >
156 <GlideMenu className="flex flex-col gap-px" highlightClassName="inset-x-0 rounded-[8px] bg-hover-2">
157 <button
158 data-menu-row
159 type="button"
160 onClick={onClose}
161 className="relative z-10 flex h-10 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
162 >
163 <span className="flex size-6 shrink-0 items-center justify-center rounded-[7px] bg-ink text-[11px] font-semibold text-surface">
164 {WORKSPACE.monogram}
165 </span>
166 <span className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink">{WORKSPACE.name}</span>
167 <span className="shrink-0 text-ink"><IconCheckmark1Small size={18} /></span>
168 </button>
169 <div className="my-1 h-px bg-line" />
170 {[
171 { label: "New workspace", icon: <IconPlusMedium size={16} /> },
172 { label: "Workspace settings", icon: <IconSettingsGear1 size={16} /> },
173 { label: "Invite team members", icon: <IconUserAdd size={16} /> },
174 ].map((item) => (
175 <button
176 key={item.label}
177 data-menu-row
178 type="button"
179 onClick={onClose}
180 className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
181 >
182 <span className="flex size-5 shrink-0 items-center justify-center text-ink-2">{item.icon}</span>
183 <span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">{item.label}</span>
184 </button>
185 ))}
186 <div className="my-1 h-px bg-line" />
187 <button
188 data-menu-row
189 type="button"
190 onClick={onClose}
191 className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
192 >
193 <span className="flex size-5 shrink-0 items-center justify-center text-ink-2"><IconArrowBoxLeft size={16} /></span>
194 <span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">Sign out</span>
195 </button>
196 </GlideMenu>
197 </div>,
198 document.body,
199 );
200}
201
202export default function SidebarNav({
203 activeTitle,
204 className = "",
205 fill = false,
206 onNewChat,
207 onPick,
208 activeNav,
209 onNavigate,
210 footerLabel = "Upgrade",
211 footerIcon,
212 onFooterClick,
213 recents = DEFAULT_RECENTS,
214}: SidebarNavProps) {
215 const [collapsed, setCollapsed] = useState(false);
216 const [internalNav, setInternalNav] = useState("chats");
217 const currentNav = activeNav ?? internalNav;
218 const selectNav = (key: string) => {
219 setInternalNav(key);
220 onNavigate?.(key);
221 };
222 const [demoActiveTitle, setDemoActiveTitle] = useState<string | null>(null);
223 const [workspaceOpen, setWorkspaceOpen] = useState(false);
224 const [workspacePosition, setWorkspacePosition] = useState({ top: 0, left: 0 });
225 const [searchOpen, setSearchOpen] = useState(false);
226 const [query, setQuery] = useState("");
227 const workspaceButtonRef = useRef<HTMLButtonElement>(null);
228 const searchRef = useRef<HTMLInputElement>(null);
229
230 const selectedTitle = activeTitle === undefined ? demoActiveTitle : activeTitle;
231 const visibleRecents = recents.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));
232
233 useEffect(() => {
234 if (!workspaceOpen) return;
235 const close = (event: PointerEvent) => {
236 const target = event.target as Element;
237 if (!target.closest("[data-workspace-trigger]") && !target.closest("[data-workspace-menu]")) {
238 setWorkspaceOpen(false);
239 }
240 };
241 document.addEventListener("pointerdown", close);
242 return () => document.removeEventListener("pointerdown", close);
243 }, [workspaceOpen]);
244
245 useEffect(() => {
246 if (searchOpen) searchRef.current?.focus();
247 }, [searchOpen]);
248
249 const collapse = () => {
250 setCollapsed(true);
251 setWorkspaceOpen(false);
252 setSearchOpen(false);
253 setQuery("");
254 };
255
256 return (
257 <aside
258 data-sidebar-collapsed={collapsed}
259 aria-label="Workspace navigation"
260 className={`relative flex shrink-0 overflow-hidden transition-[width] ${fill ? "h-full" : "h-[600px]"} ${className}`}
261 style={{
262 width: collapsed ? SIDEBAR_MOTION.collapsedWidth : SIDEBAR_MOTION.expandedWidth,
263 transitionDuration: `${SIDEBAR_MOTION.duration}ms`,
264 transitionTimingFunction: SIDEBAR_MOTION.easing,
265 "--sidebar-copy-duration": `${SIDEBAR_MOTION.copyDuration}ms`,
266 "--sidebar-copy-offset": `${SIDEBAR_MOTION.copyOffset}px`,
267 "--sidebar-easing": SIDEBAR_MOTION.easing,
268 } as CSSProperties}
269 >
270 <div className="flex min-h-0 w-[224px] shrink-0 flex-col">
271 <div className="relative mb-2.5 h-10 shrink-0">
272 <button
273 ref={workspaceButtonRef}
274 data-workspace-trigger
275 type="button"
276 aria-expanded={workspaceOpen}
277 aria-hidden={collapsed}
278 tabIndex={collapsed ? -1 : 0}
279 onClick={() => {
280 if (!workspaceOpen && workspaceButtonRef.current) {
281 const rect = workspaceButtonRef.current.getBoundingClientRect();
282 setWorkspacePosition({ top: rect.bottom + 6, left: rect.left });
283 }
284 setWorkspaceOpen((open) => !open);
285 }}
286 className="sidebar-workspace-control absolute left-2 top-1 flex h-8 w-[164px] items-center rounded-[8px] px-2 text-left transition-[background-color,transform] duration-100 hover:bg-hover-2 active:scale-[0.99]"
287 >
288 <span className="sidebar-logo flex size-5 shrink-0 items-center justify-center text-ink">
289 <IconPopsicle2 size={18} />
290 </span>
291 <span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium text-ink-2">
292 {WORKSPACE.name}
293 </span>
294 <span className="sidebar-copy ml-1 flex shrink-0 text-ink-3">
295 <IconChevronDownSmall size={16} />
296 </span>
297 </button>
298
299 {workspaceOpen && <WorkspaceMenu position={workspacePosition} onClose={() => setWorkspaceOpen(false)} />}
300
301 <button
302 type="button"
303 aria-label="Collapse sidebar"
304 aria-hidden={collapsed}
305 tabIndex={collapsed ? -1 : 0}
306 onClick={collapse}
307 className="sidebar-collapse-control absolute right-2 top-1 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink"
308 >
309 <IconSidebarLeftArrow size={18} />
310 </button>
311 <button
312 type="button"
313 aria-label="Expand sidebar"
314 aria-hidden={!collapsed}
315 tabIndex={collapsed ? 0 : -1}
316 onClick={() => setCollapsed(false)}
317 className="sidebar-expand-control absolute left-2 top-0.5 flex size-9 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink"
318 >
319 <IconSidebarLeftArrow size={18} className="rotate-180" />
320 </button>
321 </div>
322
323 <GlideGroup>
324 <RailButton
325 icon={<IconEditBig size={18} />}
326 label="New chat"
327 onClick={() => {
328 if (activeTitle === undefined) setDemoActiveTitle(null);
329 selectNav("chats");
330 onNewChat?.();
331 }}
332 />
333 {NAV_ITEMS.map((item) => (
334 <RailButton
335 key={item.key}
336 icon={item.icon}
337 label={item.label}
338 count={item.count}
339 active={currentNav === item.key}
340 onClick={() => selectNav(item.key)}
341 />
342 ))}
343 </GlideGroup>
344
345 <div className="mt-3 min-h-0 flex-1 overflow-y-auto">
346 <div className="sidebar-copy relative mx-2 mb-1 h-8">
347 <div
348 aria-hidden={searchOpen}
349 className={`absolute inset-0 flex items-center gap-1.5 px-2 text-[12.5px] font-medium text-ink-3 transition-[opacity,transform] ${searchOpen ? "pointer-events-none -translate-x-1 opacity-0" : "translate-x-0 opacity-100"}`}
350 style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`, transitionTimingFunction: CHAT_SEARCH_MOTION.easing }}
351 >
352 <IconChevronDownSmall size={16} />
353 <span>Chats</span>
354 </div>
355
356 <button
357 type="button"
358 aria-label="Search chats"
359 aria-expanded={searchOpen}
360 onClick={() => setSearchOpen(true)}
361 className={`absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color,transform] hover:bg-hover-2 hover:text-ink active:scale-[0.96] ${searchOpen ? "pointer-events-none opacity-0" : "opacity-100"}`}
362 style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms` }}
363 >
364 <IconMagnifyingGlass size={16} />
365 </button>
366
367 <div
368 className={`absolute right-0 top-0 z-20 flex h-8 items-center overflow-hidden rounded-[8px] bg-field text-ink-3 shadow-hairline transition-[width,opacity] focus-within:text-ink-2 ${searchOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
369 style={{
370 width: searchOpen ? "100%" : CHAT_SEARCH_MOTION.closedWidth,
371 transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`,
372 transitionTimingFunction: CHAT_SEARCH_MOTION.easing,
373 }}
374 >
375 <span className="ml-2 flex shrink-0 items-center justify-center">
376 <IconMagnifyingGlass size={15} />
377 </span>
378 <input
379 ref={searchRef}
380 value={query}
381 onChange={(event) => setQuery(event.target.value)}
382 onKeyDown={(event) => {
383 if (event.key === "Escape") {
384 setSearchOpen(false);
385 setQuery("");
386 }
387 }}
388 placeholder="Search chats"
389 aria-label="Search chat history"
390 className="ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3"
391 />
392 <button
393 type="button"
394 aria-label="Close chat search"
395 onClick={() => {
396 setSearchOpen(false);
397 setQuery("");
398 }}
399 className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
400 >
401 <IconCrossSmall size={16} />
402 </button>
403 </div>
404 </div>
405
406 <GlideGroup>
407 {visibleRecents.map((item) => {
408 const active = item.label === selectedTitle;
409 return (
410 <button
411 key={item.id}
412 data-row
413 type="button"
414 title={item.label}
415 onClick={() => {
416 selectNav("chats");
417 if (activeTitle === undefined) setDemoActiveTitle(item.label);
418 onPick?.(item.id, item.label, item.prompt);
419 }}
420 className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] duration-150 active:scale-[0.98] ${
421 active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""
422 }`}
423 >
424 <span className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
425 {item.label}
426 </span>
427 </button>
428 );
429 })}
430 {query && visibleRecents.length === 0 && (
431 <div className="sidebar-copy mx-2 px-2 py-2 text-[12.5px] text-ink-3">No chats found</div>
432 )}
433 </GlideGroup>
434 </div>
435
436 <div className="sidebar-copy mx-2 mt-3 w-[208px] border-t border-line pt-3">
437 <button
438 type="button"
439 onClick={onFooterClick ?? onNewChat}
440 className="flex h-8 w-full items-center justify-center gap-1.5 rounded-control bg-hover-2 text-[12.5px] font-medium text-ink transition-[background-color,transform] duration-150 hover:bg-line-strong active:scale-[0.98]"
441 >
442 {footerIcon}
443 {footerLabel}
444 </button>
445 </div>
446 </div>
447 </aside>
448 );
449}
450

Props & Specification

PropTypeDefaultDescription
variantstring&quot;default&quot;Visual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in Controls & Canvas