Skip to main content

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.

AttributeOrchestrateAccelerate Modules
Componentshadcn Sidebar + SidebarProviderCustom <aside> flex layout
CollapsibleIcon-only mode via collapsible='icon'Not collapsible (fixed w-64)
Mobileshadcn handles off-canvasSheet slide-over
User cardSidebarFooter + SidebarUserOmitted (user lives in header)
Logo (expanded)Full SVG: ANSHIN-Orchestrate-logo.svg or app-specificFull logo + name + version
Logo (collapsed)Shield only: logo-shield.svgN/A (fixed width)
App SwitcherYes — in SidebarHeaderOptional

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>
PropertyValue
w-64Fixed 256px — never make dynamic
shrink-0Never compress below 256px
border-r bg-cardSeparates from content
hidden lg:flexDesktop only — mobile uses Sheet

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)

StateFileUsage
Collapsed (icon-only)logo-shield.svgShield mark only, h-8 w-8
Expanded — light modeANSHIN-Orchestrate-logo.svgFull wordmark, h-8 auto
Expanded — dark modeANSHIN-Orchestrate-logo-dark.svgFull 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:

StateWidthLogoNav itemsUser card
Expandedw-64 (256px)Full wordmark SVGIcon + label textAvatar + name + email + chevron
Collapsedw-16 (64px)Shield icon onlyIcon 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.


<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>
PropertyValue
flex-1Fills all space between logo header and user card
px-4 py-616px horizontal, 24px vertical padding
space-y-416px gap between section groups
overflow-y-autoScrolls 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>
PropertyValue
Section labeltext-xs font-semibold text-muted-foreground
Section label caseUPPERCASE always
Section label paddingpx-3 mb-2
Item spacingspace-y-1 (4px between items)

Common section names: MAIN MENU, PRIORITY, PINNED, APPEARANCE, SETTINGS


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>
StateClasses
Activebg-primary text-primary-foreground font-medium
Inactivetext-muted-foreground hover:bg-muted transition-colors
Shapepx-3 py-2 rounded-lg
Icon sizew-4 h-4 or size-4
Icon + label gapgap-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>
StateShows
ExpandedAvatar + 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
<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>
PropertyValue
Content widthw-[--radix-dropdown-menu-trigger-width] min-w-56 (matches trigger width, min 224px)
Sidebottom
Alignend
Side offsetsideOffset={4}
HeaderAvatar + Name (font-semibold) + Email (text-xs text-muted-foreground)
ItemsProfile (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}; UserMenu sideOffset={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>
PropertyValue
Sheet widthw-[280px] (wider than desktop 256px for touch)
Sheet paddingp-0 (logo header handles its own padding)
Close triggerRoute change (via useEffect watching pathname)

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
  • SidebarProvider wraps both AppSidebar and SidebarInset at 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-foreground UPPERCASE
  • 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
  • SidebarUser trigger is size="lg" with data-[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
  • SidebarUser dropdown: w-[--radix-dropdown-menu-trigger-width] min-w-56 side="bottom" sideOffset={4}
  • SidebarUser dropdown header includes avatar (unlike header UserMenu which has no avatar)
  • See 17-APPLICATION-SWITCHER.md for sidebar header app switcher spec