UX Standards — 03: Sidebar Navigation
Governs: The left-side navigation sidebar present on all authenticated pages.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Layout Role — Full-Height Left Rail
The sidebar is a full-height left rail that spans from the very top to the very bottom of the viewport. It is NEVER inside the header or footer — it is the sibling column that header, content, and footer all sit beside.
┌─────────────────────────────────────────────────────────┐
│ SidebarProvider (full viewport) │
│ ┌──────────────┬──────────────────────────────────┐ │
│ │ │ SidebarInset │ │
│ │ AppSidebar ├──────────────────────────────────┤ │
│ │ (full │ <header> h-16 │ │
│ │ height) ├──────────────────────────────────┤ │
│ │ │ <main> flex-1 overflow-y-auto │ │
│ │ ├──────────────────────────────────┤ │
│ │ │ <footer> h-12 │ │
│ └──────────────┴──────────────────────────────────┘ │
└────────────────────────────────────────────── ───────────┘
The sidebar user card (bottom of sidebar) and the footer (bottom of right column) visually align at the same height.
Visual Anatomy
Expanded State (w-64 = 256px)
┌──────────────────────────────────────┐
│ APP SWITCHER TRIGGER (SidebarHeader)│
│ [🛡] Anshin Orchestrate ⌄ │ ← Click opens app switcher
├──────────────────────────────────────┤
│ nav (flex-1, overflow-y-auto) │
│ │
│ MAIN MENU │
│ ▶ [Icon] Dashboard │
│ ▶ [Icon] Nav Item (active) │
│ ▶ [Icon] Nav Item │
│ │
│ SECTION HEADER │
│ ▶ [Icon] Nav Item │
│ ▶ Sub Item │
│ ▶ Sub Item (active) │
│ │
├──────────────────────────────────────┤
│ USER CARD (SidebarFooter) │
│ [Avatar] Name • Email ⌄ │
└──────────────────────────────────────┘
Collapsed State (w-16 = 64px, icon-only)
┌────────┐
│ 🛡 │ ← Click opens app switcher
├────────┤
│ [ico] │
│ [ico] │ ← Active item highlighted
│ [ico] │
│ [ico] │
│ [ico] │
├────────┤
│ [Av] │ ← Avatar only (no name/email)
└────────┘
Two Sidebar Patterns
There are two sidebar implementations. Both share the same visual spec but differ in the underlying component system.
| Attribute | Orchestrate | Accelerate Modules |
|---|---|---|
| Component | shadcn Sidebar + SidebarProvider | Custom <aside> flex layout |
| Collapsible | Icon-only mode via collapsible='icon' | Not collapsible (fixed w-64) |
| Mobile | shadcn handles off-canvas | Sheet slide-over |
| User card | SidebarFooter + SidebarUser | Omitted (user lives in header) |
| Logo (expanded) | Full SVG: ANSHIN-Orchestrate-logo.svg or app-specific | Full logo + name + version |
| Logo (collapsed) | Shield only: logo-shield.svg | N/A (fixed width) |
| App Switcher | Yes — in SidebarHeader | Optional |
Outer Container
Orchestrate
// Wrapped in SidebarProvider in dashboard-layout-client.tsx
<SidebarProvider defaultOpen={true}>
<AppSidebar collapsible='icon' user={user} navItems={navConfig} />
<SidebarInset>
{/* header + main + footer */}
</SidebarInset>
</SidebarProvider>
// AppSidebar renders:
<Sidebar collapsible="icon">
<SidebarHeader>
{/* App Switcher trigger — see 17-APPLICATION-SWITCHER.md */}
</SidebarHeader>
<SidebarContent className="overflow-x-hidden">
<NavGroup navItems={navItems} />
</SidebarContent>
<SidebarFooter>
<SidebarUser user={user} />
</SidebarFooter>
<SidebarRail /> {/* The drag handle that triggers collapse */}
</Sidebar>
Accelerate Modules
<aside className="hidden lg:flex flex-col w-64 border-r bg-card shrink-0">
{/* Logo header */}
{/* Navigation */}
</aside>
| Property | Value |
|---|---|
w-64 | Fixed 256px — never make dynamic |
shrink-0 | Never compress below 256px |
border-r bg-card | Separates from content |
hidden lg:flex | Desktop only — mobile uses Sheet |
Sidebar Header — App Switcher Trigger
The top of the sidebar (SidebarHeader) contains the App Switcher trigger, not a static logo. This is a clickable button that opens the Application Switcher dropdown.
See 17-APPLICATION-SWITCHER.md for full specification.
Expanded trigger
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg">
<img
src="/images/logo-shield.svg"
alt="Anshin"
className="h-8 w-8"
/>
<div className="flex flex-col leading-tight">
<span className="font-semibold text-sm">Anshin Orchestrate</span>
<span className="text-xs text-muted-foreground truncate">
Tour & Booking Management
</span>
</div>
<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />
</SidebarMenuButton>
</DropdownMenuTrigger>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
Collapsed trigger (icon-only)
When useSidebar().state === 'collapsed', only the shield is shown — no text, no chevron. Clicking still opens the app switcher.
{isCollapsed ? (
<img src="/images/logo-shield.svg" alt="Anshin" className="h-8 w-8 mx-auto" />
) : (
<>
<img src="/images/logo-shield.svg" alt="Anshin" className="h-8 w-8" />
<div className="flex flex-col leading-tight">
<span className="font-semibold text-sm">{APP_NAME}</span>
<span className="text-xs text-muted-foreground">{APP_SUBTITLE}</span>
</div>
<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />
</>
)}
Logo Files (Orchestrate)
| State | File | Usage |
|---|---|---|
| Collapsed (icon-only) | logo-shield.svg | Shield mark only, h-8 w-8 |
| Expanded — light mode | ANSHIN-Orchestrate-logo.svg | Full wordmark, h-8 auto |
| Expanded — dark mode | ANSHIN-Orchestrate-logo-dark.svg | Full wordmark (white text), h-8 auto |
// Logo with collapse-aware switching
const { state } = useSidebar();
const isCollapsed = state === 'collapsed';
const isDark = resolvedTheme === 'dark';
{isCollapsed ? (
<img src="/images/logo-shield.svg" alt="Anshin" className="h-8 w-8" />
) : (
<img
src={isDark ? '/images/ANSHIN-Orchestrate-logo-dark.svg' : '/images/ANSHIN-Orchestrate-logo.svg'}
alt="Anshin Orchestrate"
className="h-8 w-auto max-w-[140px]"
/>
)}
Collapse Behavior
The Orchestrate sidebar uses collapsible="icon" which transitions between:
| State | Width | Logo | Nav items | User card |
|---|---|---|---|---|
| Expanded | w-64 (256px) | Full wordmark SVG | Icon + label text | Avatar + name + email + chevron |
| Collapsed | w-16 (64px) | Shield icon only | Icon only (tooltip on hover) | Avatar only |
The collapse is triggered by:
- Clicking the
SidebarRail(the thin drag handle on the right edge of the sidebar) - Programmatically via
useSidebar().toggleSidebar()
In collapsed mode, items with sub-menus use a HoverCard popout to the right — see Collapsible Submenu section below.
Navigation Container
<nav className="flex-1 px-4 py-6 space-y-4 overflow-y-auto" role="navigation" aria-label="Main navigation">
{/* Section groups — each has a header + items */}
</nav>
| Property | Value |
|---|---|
flex-1 | Fills all space between logo header and user card |
px-4 py-6 | 16px horizontal, 24px vertical padding |
space-y-4 | 16px gap between section groups |
overflow-y-auto | Scrolls if nav items exceed viewport |
Section Groups
<div>
<p className="px-3 text-xs font-semibold text-muted-foreground mb-2">
SECTION NAME
</p>
<div className="space-y-1">
{/* Nav items */}
</div>
</div>
| Property | Value |
|---|---|
| Section label | text-xs font-semibold text-muted-foreground |
| Section label case | UPPERCASE always |
| Section label padding | px-3 mb-2 |
| Item spacing | space-y-1 (4px between items) |
Common section names: MAIN MENU, PRIORITY, PINNED, APPEARANCE, SETTINGS
Nav Item States
Standard Item (Accelerate)
// Active state
<Link href={path} className="px-3 py-2 rounded-lg flex items-center gap-2 bg-primary text-primary-foreground font-medium">
<Icon className="w-4 h-4" />
Item Label
</Link>
// Inactive state
<Link href={path} className="px-3 py-2 rounded-lg flex items-center gap-2 text-muted-foreground hover:bg-muted transition-colors">
<Icon className="w-4 h-4" />
Item Label
</Link>
Dynamic Active Detection
// Helper function — determines active based on exact match OR prefix match
const navLinkClasses = (path: string, exact = false) => {
const isActive = exact
? pathname === path
: pathname === path || pathname.startsWith(path + '/')
return `px-3 py-2 rounded-lg flex items-center gap-2 ${
isActive
? 'bg-primary text-primary-foreground font-medium'
: 'text-muted-foreground hover:bg-muted transition-colors'
}`
}
Standard Item (Orchestrate — shadcn)
// Uses SidebarMenuButton with isActive prop
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={title} isActive={pathname === item.url}>
<Link href={item.url}>
<Icon className="size-4" />
<span>{title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
| State | Classes |
|---|---|
| Active | bg-primary text-primary-foreground font-medium |
| Inactive | text-muted-foreground hover:bg-muted transition-colors |
| Shape | px-3 py-2 rounded-lg |
| Icon size | w-4 h-4 or size-4 |
| Icon + label gap | gap-2 |
Collapsible Submenu (Orchestrate)
When a nav item has children, it renders a collapsible tree in expanded mode, and a HoverCard popout in collapsed mode.
Expanded (Full Sidebar)
<Collapsible asChild defaultOpen={isMenuActive} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton isActive={isMenuActive}>
<Icon className="size-4" />
<span>{title}</span>
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.titleKey}>
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
<Link href={subItem.url}>
<span>{t(subItem.titleKey)}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
Collapsed (Icon-Only Mode)
// Shows a HoverCard that pops out to the right on hover
<HoverCard openDelay={200} closeDelay={150}>
<HoverCardTrigger asChild>
<SidebarMenuButton isActive={isMenuActive}>
<Icon className="size-4" />
</SidebarMenuButton>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" sideOffset={8} className="w-48 p-2">
<div className="px-2 pb-1.5 text-sm font-medium text-foreground">{title}</div>
<div className="space-y-0.5">
{item.items.map((sub) => (
<Link
key={sub.titleKey}
href={sub.url}
className={cn(
'block rounded-md px-2 py-1.5 text-sm transition-colors',
pathname === sub.url
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
{t(sub.titleKey)}
</Link>
))}
</div>
</HoverCardContent>
</HoverCard>
Badge Counts on Nav Items
Use a count badge when a nav item has pending items (e.g., unread faxes, pending tasks).
<Link href={path} className={navLinkClasses(path, true)}>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary"></span>
Nav Label
</span>
{count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded ${
isActive ? 'bg-primary-foreground/20' : 'bg-muted'
}`}>
{count}
</span>
)}
</Link>
Permission-Gated Items
Items that require permissions are wrapped in RequirePermission:
// Accelerate pattern
import { RequirePermission } from '@/lib/use-permissions';
import { PermissionKeys } from '@/lib/types/rbac';
<RequirePermission permission={PermissionKeys.VIEW_FAXES}>
<Link href="/" className={navLinkClasses('/', true)}>
<InboxIcon className="w-4 h-4" />
Inbox
</Link>
</RequirePermission>
Rule: Never show nav items the user cannot access. RequirePermission renders null when the user lacks the permission. This avoids navigation errors and confusion.
Pinned Settings Section (Accelerate)
Users can pin frequently-used settings pages to the main nav for quick access:
{pinnedSettings.length > 0 && (
<div>
<p className="px-3 text-xs font-semibold text-muted-foreground mb-2">PINNED</p>
<div className="space-y-1">
{pinnedSettings.map(path => {
const setting = PINNABLE_SETTINGS.find(s => s.path === path)
if (!setting) return null
return (
<div key={path} className="flex items-center group">
<Link href={path} className={`${navLinkClasses(path, true)} flex-1`}>
<span className="text-sm">{setting.icon}</span>
{setting.label}
</Link>
<button
onClick={() => togglePin(path)}
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-muted rounded transition-opacity"
title="Unpin"
>
<PinOff className="w-3 h-3 text-muted-foreground" />
</button>
</div>
)
})}
</div>
</div>
)}
Indented Sub-Items
Items that belong to a parent but are not in a collapsible group use ml-4 indentation:
<Link
href="/settings/sandbox"
className={`${navLinkClasses('/settings/sandbox', true)} ml-4`}
>
<BeakerIcon className="w-4 h-4" />
Sandbox
</Link>
User Card (Orchestrate — SidebarFooter)
Component: packages/shared/components/layouts/sidebar-user.tsx
Renders in: SidebarFooter at the bottom of the sidebar.
<SidebarUser user={user} isLoading={isUserLoading} onLogout={onLogout} />
Trigger (the visible card)
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{/* Avatar always visible */}
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary text-xs font-medium text-primary-foreground">
{initials}
</AvatarFallback>
</Avatar>
{/* Expanded mode only: name + email + chevron */}
{!isCollapsed && (
<>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">{displayEmail}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</>
)}
</SidebarMenuButton>
| State | Shows |
|---|---|
| Expanded | Avatar + Name (font-semibold) + Email (text-xs text-muted-foreground) + ChevronsUpDown icon |
| Collapsed (icon-only) | Avatar only |
| Open (dropdown active) | data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground |
DropdownMenu
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
{/* Header: avatar + name + email */}
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary text-xs font-medium text-primary-foreground">
{initials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">{displayEmail}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
| Property | Value |
|---|---|
| Content width | w-[--radix-dropdown-menu-trigger-width] min-w-56 (matches trigger width, min 224px) |
| Side | bottom |
| Align | end |
| Side offset | sideOffset={4} |
| Header | Avatar + Name (font-semibold) + Email (text-xs text-muted-foreground) |
| Items | Profile (User icon), Settings (Settings icon), separator, Log out (LogOut icon) |
Note: The SidebarUser dropdown is DIFFERENT from the header UserMenu dropdown:
- SidebarUser header has avatar in the dropdown header; UserMenu header does NOT
- SidebarUser content width matches trigger width; UserMenu is fixed
w-56 - SidebarUser
side="bottom" sideOffset={4}; UserMenusideOffset={8}
Loading Skeleton
When permissions are still loading, render a skeleton sidebar to prevent blank flicker:
if (isLoading) {
return (
<aside className="hidden lg:flex flex-col w-64 border-r bg-card">
<div className="px-6 py-5 border-b">
{/* Logo skeleton */}
</div>
<nav className="flex-1 px-4 py-6 space-y-4" aria-label="Main navigation loading">
<div className="space-y-1" role="status" aria-label="Loading menu items">
<span className="sr-only">Loading navigation menu...</span>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="px-3 py-2 rounded-lg flex items-center gap-2" aria-hidden="true">
<div className="w-4 h-4 rounded bg-muted animate-pulse" />
<div className="h-4 rounded bg-muted animate-pulse" style={{ width: `${60 + i * 10}px` }} />
</div>
))}
</div>
</nav>
</aside>
)
}
Mobile — Sheet Slide-Over
On mobile (< lg), the sidebar is hidden. A hamburger button opens it as a Sheet from the left:
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open menu">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[280px] p-0 flex flex-col">
<SheetHeader className="px-6 py-5 border-b">
{/* Logo area — same as desktop */}
</SheetHeader>
<NavigationContent /> {/* Same nav, same classes */}
</SheetContent>
</Sheet>
| Property | Value |
|---|---|
| Sheet width | w-[280px] (wider than desktop 256px for touch) |
| Sheet padding | p-0 (logo header handles its own padding) |
| Close trigger | Route change (via useEffect watching pathname) |
Nav Configuration (Orchestrate)
Nav items are defined centrally in a config file, not hardcoded in the component:
// packages/shared/components/layouts/nav-config.ts
export interface NavItem {
titleKey: string; // i18n key
url: string; // Route path
icon: LucideIcon; // Lucide icon component
featureFlag?: OrchFeatureFlag; // Optional feature flag gate
items?: NavItem[]; // Sub-items for collapsible groups
}
export const navItems: NavItem[] = [
{
titleKey: 'nav.dashboard',
url: '/dashboard',
icon: LayoutDashboard,
},
{
titleKey: 'nav.masterData',
url: '/master-data',
icon: Database,
items: [
{ titleKey: 'nav.productSetup', url: '/master-data/product-setup', icon: Package },
{ titleKey: 'nav.providers', url: '/master-data/providers', icon: Building2 },
]
},
// ...
]
Accelerate modules: Nav items are hardcoded in the layout component since modules have small, stable nav sets. As modules grow, extract to a config file.
Violation Checklist
- Sidebar is a full-height left rail — NOT inside header, content, or footer
-
SidebarProviderwraps bothAppSidebarandSidebarInsetat the same level - Sidebar header contains App Switcher trigger (not a static logo link)
- Expanded: shows full app wordmark SVG + app name + subtitle +
ChevronsUpDown - Collapsed: shows shield icon only (
logo-shield.svg) — no text, no chevron - Sidebar uses
w-64 border-r bg-card shrink-0 - Sidebar is
hidden lg:flex(mobile: Sheet) - Logo header uses
px-6 py-5 border-b - App icon uses
bg-primary text-primary-foreground rounded-xl - Section labels are
text-xs font-semibold text-muted-foregroundUPPERCASE - Nav items are
px-3 py-2 rounded-lg flex items-center gap-2 - Active state:
bg-primary text-primary-foreground font-medium - Inactive state:
text-muted-foreground hover:bg-muted transition-colors - Icons are
w-4 h-4(Lucide only) - Permission-gated items wrapped in
RequirePermission - Loading state shows skeleton, not blank
- No hardcoded colors anywhere in nav
-
SidebarUsertrigger issize="lg"withdata-[state=open]:bg-sidebar-accent - Collapsed mode: avatar only — NO name, email, or chevron shown
- Expanded mode: Avatar + Name (
font-semibold) + Email (text-xs text-muted-foreground) +ChevronsUpDown -
SidebarUserdropdown:w-[--radix-dropdown-menu-trigger-width] min-w-56 side="bottom" sideOffset={4} -
SidebarUserdropdown header includes avatar (unlike header UserMenu which has no avatar) - See
17-APPLICATION-SWITCHER.mdfor sidebar header app switcher spec