Sidebar Nav
Collapsible workspace and chat navigation with gliding hover states.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/sidebar-nav.css";
import "./styles/foundation.css";
import { PanelLeftClose, Check, ChevronDown, X, Pencil, House, Search, Plus, Sparkles, Settings, PanelLeftOpen, UserRoundPlus } from "lucide-react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import GlideMenu from "./GlideMenu";
/* ─────────────────────────────────────────────────────────
* SIDEBAR NAV
* Shared by the design-system preview and the harness shell:
* compact workspace switcher, primary navigation, searchable
* chat history, and a collapse that preserves icon alignment.
* ───────────────────────────────────────────────────────── */
const WORKSPACE = { key: "studio", name: "Studio Ops", monogram: "C" };
const NAV_ITEMS = [
{ key: "home", label: "Home", icon: <House size={18} /> },
{ key: "invite", label: "Invite users", icon: <UserRoundPlus size={18} />, count: "3/10" },
];
export type SidebarRecent = {
id: string;
label: string;
prompt?: string;
};
const DEFAULT_RECENTS: SidebarRecent[] = [
{ id: "suppliers", label: "Supplier records" },
{ id: "todos", label: "Urgent to-dos this morning" },
{ id: "project", label: "Project page ticket" },
{ id: "workload", label: "Workload summary" },
{ id: "offboarding", label: "Off-board a supplier" },
{ id: "restock", label: "Batch restock function" },
{ id: "edits", label: "Propose project edits" },
{ id: "background", label: "Background surfing" },
];
type SidebarNavProps = {
activeTitle?: string | null;
className?: string;
fill?: boolean;
onNewChat?: () => void;
onPick?: (id: string, label: string, prompt?: string) => void;
/** controlled primary-nav selection (e.g. "home" | "invite") */
activeNav?: string;
onNavigate?: (key: string) => void;
/** footer call-to-action — defaults to the demo "Upgrade" button */
footerLabel?: string;
footerIcon?: ReactNode;
onFooterClick?: () => void;
recents?: SidebarRecent[];
variant?: string;
};
const SIDEBAR_MOTION = {
expandedWidth: 224,
collapsedWidth: 52,
duration: 280,
copyDuration: 180,
copyOffset: 8,
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
};
/* ─────────────────────────────────────────────────────────
* CHAT SEARCH STORYBOARD
*
* 0ms search is triggered; Chats label begins fading
* 0ms field grows right → left from the search control
* 180ms field fills the row; cursor is focused and ready
* ───────────────────────────────────────────────────────── */
const CHAT_SEARCH_MOTION = {
duration: 180,
closedWidth: 28,
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
};
function GlideGroup({ children }: { children: ReactNode }) {
return (
<GlideMenu
rowSelector="[data-row]"
highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2"
className="group/glide flex flex-col gap-px"
>
{children}
</GlideMenu>
);
}
function RailButton({
icon,
label,
active = false,
count,
onClick,
}: {
icon: ReactNode;
label: string;
active?: boolean;
count?: string;
onClick?: () => void;
}) {
return (
<button
data-row
type="button"
onClick={onClick}
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]
${active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""}`}
>
<span className={`flex size-5 shrink-0 items-center justify-center ${active ? "text-ink" : "text-ink-2"}`}>
{icon}
</span>
<span className={`sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
{label}
</span>
{count && (
<span className="sidebar-copy mr-2 shrink-0 text-[12px] font-medium tabular-nums text-ink-3">
{count}
</span>
)}
</button>
);
}
function WorkspaceMenu({
position,
onClose,
}: {
position: { top: number; left: number };
onClose: () => void;
}) {
return createPortal(
<div
data-workspace-menu
className="fixed z-50 w-64 rounded-[14px] bg-card p-1.5 shadow-overlay"
style={{
top: position.top,
left: position.left,
animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both",
transformOrigin: "top left",
}}
>
<GlideMenu className="flex flex-col gap-px" highlightClassName="inset-x-0 rounded-[8px] bg-hover-2">
<button
data-menu-row
type="button"
onClick={onClose}
className="relative z-10 flex h-10 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-[7px] bg-ink text-[11px] font-semibold text-card">
{WORKSPACE.monogram}
</span>
<span className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink">{WORKSPACE.name}</span>
<span className="shrink-0 text-ink"><Check size={18} /></span>
</button>
<div className="my-1 h-px bg-line" />
{[
{ label: "New workspace", icon: <Plus size={16} /> },
{ label: "Workspace settings", icon: <Settings size={16} /> },
{ label: "Invite team members", icon: <UserRoundPlus size={16} /> },
].map((item) => (
<button
key={item.label}
data-menu-row
type="button"
onClick={onClose}
className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
>
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">{item.icon}</span>
<span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">{item.label}</span>
</button>
))}
<div className="my-1 h-px bg-line" />
<button
data-menu-row
type="button"
onClick={onClose}
className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
>
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2"><PanelLeftClose size={16} /></span>
<span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">Sign out</span>
</button>
</GlideMenu>
</div>,
document.body,
);
}
export default function SidebarNav({
activeTitle,
className = "",
fill = false,
onNewChat,
onPick,
activeNav,
onNavigate,
footerLabel = "Upgrade",
footerIcon,
onFooterClick,
recents = DEFAULT_RECENTS,
}: SidebarNavProps) {
const [collapsed, setCollapsed] = useState(false);
const [internalNav, setInternalNav] = useState("chats");
const currentNav = activeNav ?? internalNav;
const selectNav = (key: string) => {
setInternalNav(key);
onNavigate?.(key);
};
const [demoActiveTitle, setDemoActiveTitle] = useState<string | null>(null);
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const [workspacePosition, setWorkspacePosition] = useState({ top: 0, left: 0 });
const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState("");
const workspaceButtonRef = useRef<HTMLButtonElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const selectedTitle = activeTitle === undefined ? demoActiveTitle : activeTitle;
const visibleRecents = recents.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));
useEffect(() => {
if (!workspaceOpen) return;
const close = (event: PointerEvent) => {
const target = event.target as Element;
if (!target.closest("[data-workspace-trigger]") && !target.closest("[data-workspace-menu]")) {
setWorkspaceOpen(false);
}
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [workspaceOpen]);
useEffect(() => {
if (searchOpen) searchRef.current?.focus();
}, [searchOpen]);
const collapse = () => {
setCollapsed(true);
setWorkspaceOpen(false);
setSearchOpen(false);
setQuery("");
};
return (
<aside
data-sidebar-collapsed={collapsed}
aria-label="Workspace navigation"
className={`relative flex shrink-0 overflow-hidden transition-[width] ${fill ? "h-full" : "h-[600px]"} ${className}`}
style={{
width: collapsed ? SIDEBAR_MOTION.collapsedWidth : SIDEBAR_MOTION.expandedWidth,
transitionDuration: `${SIDEBAR_MOTION.duration}ms`,
transitionTimingFunction: SIDEBAR_MOTION.easing,
"--sidebar-copy-duration": `${SIDEBAR_MOTION.copyDuration}ms`,
"--sidebar-copy-offset": `${SIDEBAR_MOTION.copyOffset}px`,
"--sidebar-easing": SIDEBAR_MOTION.easing,
} as CSSProperties}
>
<div className="flex min-h-0 w-[224px] shrink-0 flex-col">
<div className="relative mb-2.5 h-10 shrink-0">
<button
ref={workspaceButtonRef}
data-workspace-trigger
type="button"
aria-expanded={workspaceOpen}
aria-hidden={collapsed}
tabIndex={collapsed ? -1 : 0}
onClick={() => {
if (!workspaceOpen && workspaceButtonRef.current) {
const rect = workspaceButtonRef.current.getBoundingClientRect();
setWorkspacePosition({ top: rect.bottom + 6, left: rect.left });
}
setWorkspaceOpen((open) => !open);
}}
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]"
>
<span className="sidebar-logo flex size-5 shrink-0 items-center justify-center text-ink">
<Sparkles size={18} />
</span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium text-ink-2">
{WORKSPACE.name}
</span>
<span className="sidebar-copy ml-1 flex shrink-0 text-ink-3">
<ChevronDown size={16} />
</span>
</button>
{workspaceOpen && <WorkspaceMenu position={workspacePosition} onClose={() => setWorkspaceOpen(false)} />}
<button
type="button"
aria-label="Collapse sidebar"
aria-hidden={collapsed}
tabIndex={collapsed ? -1 : 0}
onClick={collapse}
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"
>
<PanelLeftOpen size={18} />
</button>
<button
type="button"
aria-label="Expand sidebar"
aria-hidden={!collapsed}
tabIndex={collapsed ? 0 : -1}
onClick={() => setCollapsed(false)}
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"
>
<PanelLeftOpen size={18} className="rotate-180" />
</button>
</div>
<GlideGroup>
<RailButton
icon={<Pencil size={18} />}
label="New chat"
onClick={() => {
if (activeTitle === undefined) setDemoActiveTitle(null);
selectNav("chats");
onNewChat?.();
}}
/>
{NAV_ITEMS.map((item) => (
<RailButton
key={item.key}
icon={item.icon}
label={item.label}
count={item.count}
active={currentNav === item.key}
onClick={() => selectNav(item.key)}
/>
))}
</GlideGroup>
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
<div className="sidebar-copy relative mx-2 mb-1 h-8">
<div
aria-hidden={searchOpen}
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"}`}
style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`, transitionTimingFunction: CHAT_SEARCH_MOTION.easing }}
>
<ChevronDown size={16} />
<span>Chats</span>
</div>
<button
type="button"
aria-label="Search chats"
aria-expanded={searchOpen}
onClick={() => setSearchOpen(true)}
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"}`}
style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms` }}
>
<Search size={16} />
</button>
<div
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"}`}
style={{
width: searchOpen ? "100%" : CHAT_SEARCH_MOTION.closedWidth,
transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`,
transitionTimingFunction: CHAT_SEARCH_MOTION.easing,
}}
>
<span className="ml-2 flex shrink-0 items-center justify-center">
<Search size={15} />
</span>
<input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setSearchOpen(false);
setQuery("");
}
}}
placeholder="Search chats"
aria-label="Search chat history"
className="ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3"
/>
<button
type="button"
aria-label="Close chat search"
onClick={() => {
setSearchOpen(false);
setQuery("");
}}
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]"
>
<X size={16} />
</button>
</div>
</div>
<GlideGroup>
{visibleRecents.map((item) => {
const active = item.label === selectedTitle;
return (
<button
key={item.id}
data-row
type="button"
title={item.label}
onClick={() => {
selectNav("chats");
if (activeTitle === undefined) setDemoActiveTitle(item.label);
onPick?.(item.id, item.label, item.prompt);
}}
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] ${
active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""
}`}
>
<span className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
{item.label}
</span>
</button>
);
})}
{query && visibleRecents.length === 0 && (
<div className="sidebar-copy mx-2 px-2 py-2 text-[12.5px] text-ink-3">No chats found</div>
)}
</GlideGroup>
</div>
<div className="sidebar-copy mx-2 mt-3 w-[208px] border-t border-line pt-3">
<button
type="button"
onClick={onFooterClick ?? onNewChat}
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]"
>
{footerIcon}
{footerLabel}
</button>
</div>
</div>
</aside>
);
}/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — SidebarNav
* The 52↔224px collapsible sidebar tree.
* Copy this block only alongside SidebarNav; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.sidebar-row {
width: 208px;
transition-duration: var(--sidebar-copy-duration);
transition-timing-function: var(--sidebar-easing);
}
.sidebar-copy {
opacity: 1;
transform: translateX(0);
transition:
opacity var(--sidebar-copy-duration) ease-out,
transform var(--sidebar-copy-duration) var(--sidebar-easing);
}
.sidebar-logo,
.sidebar-collapse-control,
.sidebar-expand-control {
transition-duration: var(--sidebar-copy-duration);
transition-timing-function: ease-out;
}
.sidebar-expand-control {
pointer-events: none;
opacity: 0;
}
.sidebar-glide-highlight {
right: 8px;
left: 8px;
}
[data-sidebar-collapsed="true"] .sidebar-row {
width: 36px;
}
[data-sidebar-collapsed="true"] .sidebar-copy {
pointer-events: none;
opacity: 0;
transform: translateX(calc(var(--sidebar-copy-offset) * -1));
}
[data-sidebar-collapsed="true"] .sidebar-workspace-control {
pointer-events: none;
}
[data-sidebar-collapsed="true"] .sidebar-logo,
[data-sidebar-collapsed="true"] .sidebar-collapse-control {
pointer-events: none;
opacity: 0;
}
[data-sidebar-collapsed="true"] .sidebar-expand-control {
pointer-events: auto;
opacity: 1;
}
[data-sidebar-collapsed="true"] .sidebar-glide-highlight {
right: auto;
width: 36px;
}
@media (prefers-reduced-motion: reduce) {
.home-reveal {
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}
/* ── records table ────────────────────────────────────────
* The grid is deliberately more architectural than card-like:
* one quiet surface, precise rules, and color reserved for
* meaning. The first column remains an anchor while both axes
* can move independently inside the table viewport.
*/Installation
pnpm dlx shadcn@latest add @ward/sidebar-navnpx shadcn@latest add @ward/sidebar-navyarn dlx shadcn@latest add @ward/sidebar-navbunx --bun shadcn@latest add @ward/sidebar-navAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/sidebar-nav.json
Install the dependencies.
npm install lucide-react@1.47.0Add the Ward items it builds on.
npx shadcn@latest add @ward/foundation @ward/glide-menuCopy each file from the Code tab into:
components/ward/SidebarNav.tsxcomponents/ward/styles/sidebar-nav.css
Update the import paths to match your project.
Usage
import SidebarNav from "@/components/ward/SidebarNav";<SidebarNav
fill
activeNav="home"
onNavigate={(key) => console.log(key)}
recents={[
{ id: "suppliers", label: "Supplier records" },
{ id: "workload", label: "Workload summary", prompt: "Summarise this week's workload" },
]}
onPick={(id, label) => console.log(id, label)}
onNewChat={() => console.log("new chat")}
footerLabel="Upgrade"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | string | "Default" | Accepted for registry parity; the component has a single variant. |
activeTitle | string | null | - | Label of the highlighted chat; when left undefined the sidebar tracks the selection itself. |
className | string | "" | Extra classes added to the sidebar element. |
fill | boolean | false | Makes the sidebar take the full height of its parent instead of a fixed 600px. |
onNewChat | () => void | - | Called when the new chat button is pressed, and by the footer button when onFooterClick is not set. |
onPick | (id: string, label: string, prompt?: string) => void | - | Called with the chat's id, label and prompt when a recent chat is chosen. |
activeNav | string | - | Key of the selected primary navigation item, such as "home" or "invite", for controlled use. |
onNavigate | (key: string) => void | - | Called with the item key when the primary navigation selection changes. |
footerLabel | string | "Upgrade" | Text on the footer button. |
footerIcon | ReactNode | - | Icon shown before the footer button text. |
onFooterClick | () => void | - | Called when the footer button is pressed. |
recents | SidebarRecent[] | DEFAULT_RECENTS | Recent chats listed under the navigation and filtered by the chat search field. |
Ward · Version 1.1.0 · Registry manifest