Sidebar Nav
Controls & CanvasCollapsible workspace and chat navigation with gliding hover states.
Interactive Live Preview
components/ui/sidebar-nav.tsxTypeScript · React 19
1"use client";23import { 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";1819/* ─────────────────────────────────────────────────────────20 * SIDEBAR NAV21 * Shared by the design-system preview and the harness shell:22 * compact workspace switcher, primary navigation, searchable23 * chat history, and a collapse that preserves icon alignment.24 * ───────────────────────────────────────────────────────── */2526const WORKSPACE = { key: "creamery", name: "Creamery Ops", monogram: "C" };2728const 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];3233export type SidebarRecent = {34 id: string;35 label: string;36 prompt?: string;37};3839const 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];4950type 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};6667const 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};7576/* ─────────────────────────────────────────────────────────77 * CHAT SEARCH STORYBOARD78 *79 * 0ms search is triggered; Chats label begins fading80 * 0ms field grows right → left from the search control81 * 180ms field fills the row; cursor is focused and ready82 * ───────────────────────────────────────────────────────── */83const CHAT_SEARCH_MOTION = {84 duration: 180,85 closedWidth: 28,86 easing: "cubic-bezier(0.16, 1, 0.3, 1)",87};8889function GlideGroup({ children }: { children: ReactNode }) {90 return (91 <GlideMenu92 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}100101function 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 <button116 data-row117 type="button"118 onClick={onClick}119 className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left120 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}137138function WorkspaceMenu({139 position,140 onClose,141}: {142 position: { top: number; left: number };143 onClose: () => void;144}) {145 return createPortal(146 <div147 data-workspace-menu148 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 <button158 data-menu-row159 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 <button176 key={item.label}177 data-menu-row178 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 <button188 data-menu-row189 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}201202export 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);229230 const selectedTitle = activeTitle === undefined ? demoActiveTitle : activeTitle;231 const visibleRecents = recents.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));232233 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]);244245 useEffect(() => {246 if (searchOpen) searchRef.current?.focus();247 }, [searchOpen]);248249 const collapse = () => {250 setCollapsed(true);251 setWorkspaceOpen(false);252 setSearchOpen(false);253 setQuery("");254 };255256 return (257 <aside258 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 <button273 ref={workspaceButtonRef}274 data-workspace-trigger275 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>298299 {workspaceOpen && <WorkspaceMenu position={workspacePosition} onClose={() => setWorkspaceOpen(false)} />}300301 <button302 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 <button312 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>322323 <GlideGroup>324 <RailButton325 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 <RailButton335 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>344345 <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 <div348 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>355356 <button357 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>366367 <div368 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 <input379 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 <button393 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>405406 <GlideGroup>407 {visibleRecents.map((item) => {408 const active = item.label === selectedTitle;409 return (410 <button411 key={item.id}412 data-row413 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>435436 <div className="sidebar-copy mx-2 mt-3 w-[208px] border-t border-line pt-3">437 <button438 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
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |