UX Standards — 02: Header
Governs: The universal sticky header bar present on every authenticated page.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Layout Position — CRITICAL
The header lives inside SidebarInset — the right column of the layout. It does NOT span the full page width. The sidebar is a full-height left rail; the header, main content, and footer are all contained in the right column.
┌─────────────────────────────────────────────────────────┐
│ SidebarProvider │
│ ┌─────────────┬───────────────────────────────────┐ │
│ │ AppSidebar │ SidebarInset (right column) │ │
│ │ (left rail) ├───────────────────────────────────┤ │
│ │ │ ◀ HEADER lives here — right column │ │
│ │ ├───────────────────────────────────┤ │
│ │ │ main content │ │
│ │ ├───────────────────────────────────┤ │
│ │ │ footer │ │
│ └─────────────┴───────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
// Correct layout structure — header is always inside SidebarInset
<SidebarProvider>
<AppSidebar /> {/* Full-height left rail */}
<SidebarInset> {/* Right column */}
<header className="sticky top-0 z-50 ...">...</header>
<main>{children}</main>
<footer>...</footer>
</SidebarInset>
</SidebarProvider>
Never place <header> as a sibling of <AppSidebar> at the SidebarProvider level — that would incorrectly span it over the sidebar.
Visual Anatomy
┌───────────────────────── ───────────────────────────────────────────────────┐
│ h-16 (64px) │ sticky top-0 z-50 │ border-b │ bg-background │ px-4 │
│ │
│ LEFT ZONE CENTER ZONE RIGHT ZONE │
│ [Page Title] [Company Logo] [Anna][🔍][🔔][?][👤]│
│ [Page Subtitle] [Company Name] [🌐][Dark][Settings] │
└────────────────────────────────────────────────────────────────────────────┘
Full Header Specification
Container
<header
className="sticky top-0 z-50 flex h-16 w-full shrink-0 items-center justify-between gap-2 border-b bg-background px-4"
>
| Property | Value | Reason |
|---|---|---|
sticky top-0 | Always visible on scroll | Content scrolls under it |
z-50 | Above all page content | Dropdowns, tooltips included |
h-16 | 64px exactly | Non-negotiable across all apps |
border-b | 1px bottom border | Visual zone separation |
bg-background | Theme-aware | Works in dark/light mode |
px-4 | 16px horizontal padding | Breathing room |
justify-between | 3-zone layout | Left, center, right |
Left Zone — Page Title and Subtitle
The left zone displays the current page's title and optional subtitle.
// HeaderLeft component
<div className="flex flex-col min-w-0">
<h1 className="text-lg font-semibold leading-none tracking-tight truncate">
{pageTitle}
</h1>
{pageDescription && (
<p className="text-sm text-muted-foreground leading-none mt-0.5 truncate">
{pageDescription}
</p>
)}
</div>
Title Rules
| Property | Spec |
|---|---|
| Font size | text-lg (18px) |
| Font weight | font-semibold |
| Line height | leading-none |
| Tracking | tracking-tight |
| Overflow | truncate (never wrap) |
| Color | text-foreground (default, no color class needed) |
Subtitle Rules
| Property | Spec |
|---|---|
| Font size | text-sm (14px) |
| Color | text-muted-foreground |
| Line height | leading-none |
| Margin top | mt-0.5 (2px above) |
| Overflow | truncate |
| Content | Describes what the page manages (e.g., "Manage your users and their roles here.") |
Page Title Resolution
In Orchestrate — titles are resolved centrally from a pageTitleMap:
// packages/shared/components/layouts/page-titles.ts
export const pageTitleMap: Record<string, PageTitleConfig> = {
'/master-data/product-setup': {
titleKey: 'masterData.productSetup.title',
descriptionKey: 'masterData.productSetup.description'
},
'/dashboard/users': {
titleKey: 'users.list.title',
descriptionKey: 'users.list.description'
},
// ... one entry per page route
};
Never hardcode page titles inside page components. Register them in the central map.
Override context — for dynamic titles (e.g., a user's name on their detail page):
import { usePageTitleOverride } from '@/components/layouts/page-title-override-context';
// In a detail page:
const { setTitle } = usePageTitleOverride();
useEffect(() => {
setTitle({ title: user.name, description: `ID: ${user.id}` });
}, [user]);
In Accelerate modules — until a central map is created, define title+subtitle as constants at the top of the page component:
const PAGE_TITLE = 'Fax Inbox';
const PAGE_SUBTITLE = 'Review and process incoming faxes.';
Center Zone — Company Logo (Optional)
Displayed only when showCompanyLogo={true} (default: true in Orchestrate).
<div className="flex items-center gap-2 absolute left-1/2 -translate-x-1/2">
{companyLogo ? (
<img src={companyLogo} alt={companyName} className="h-8 max-w-[120px] object-contain" />
) : (
<span className="text-sm font-medium text-muted-foreground">{companyName}</span>
)}
</div>
This is the TENANT'S logo (e.g., "TAO Travel 365"), not the Anshin application logo. The Anshin app logo lives in the sidebar header (App Switcher trigger).
Accelerate modules: Center zone is typically omitted. The app logo lives in the sidebar header instead.
Right Zone — Action Icons
Icons appear in this fixed order, left to right:
| Position | Icon | Component | Shows When |
|---|---|---|---|
| 1 | Search | Search h-5 w-5 ghost button | showSearch={true} |
| 2 | Language selector | LanguageSelector | showLanguageSelector={true} |
| 3 | Notifications | notificationSlot (injected) | showNotifications={true} |
| 4 | Help / Helpdesk | Headphones h-5 w-5 ghost button | showHelpdesk={true} |
| 5 | Tenant filter | tenantFilterSlot (injected) | when slot provided |
| 6 | Anna AI button | AnnaButton | showAnna={true} |
| 7 | Dark mode toggle | ModeToggle | Always |
| 8 | Separator | Separator orientation="vertical" mx-1 h-6 | Always |
| 9 | User avatar / account | UserMenu | Always |
Source: packages/shared/components/layouts/header-sections.tsx — HeaderRight component.
Search Button
<Button variant="ghost" size="icon" className="h-9 w-9">
<Search className="h-5 w-5" />
<span className="sr-only">{t('header.search')}</span>
</Button>
Tooltip: Shows t('header.search') text. On apps with ⌘K, the tooltip shows "Search (⌘K)" or "Search (Ctrl+K)".
Note: The Search button in header-sections.tsx does NOT wire the ⌘K shortcut — that is registered at the app level. See 16-COMMAND-PALETTE.md.
<div className="flex items-center gap-1 ml-auto">
{showAnna && <AnnaButton anna={anna} />}
{showSearch && <CommandSearchButton />}
{notificationSlot} {/* Injected from parent */}
{showHelpdesk && <HelpdeskButton onClick={onHelpdeskClick} />}
{showLanguageSelector && <LanguageSelector />}
<ThemeToggle />
<UserMenu
displayName={displayName}
displayEmail={displayEmail}
initials={initials}
onLogout={onLogout}
/>
</div>
Icon Button Standard
All header icon buttons must follow:
<Button
variant="ghost"
size="icon"
className="h-9 w-9" // Standard: 36px square
aria-label="Description of action"
>
<IconComponent className="h-4 w-4" /> // 16px icon
</Button>
User Avatar / Menu (Header — UserMenu)
The header user menu is the UserMenu component in packages/shared/components/layouts/header-user-menu.tsx.
// Trigger: ghost button with avatar
<Button variant="ghost" className="relative h-9 w-9 rounded-lg p-0">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary text-xs font-medium text-primary-foreground">
{initials} {/* First 2 chars of name, uppercase */}
</AvatarFallback>
</Avatar>
</Button>
// DropdownMenuContent spec:
<DropdownMenuContent className="w-56" align="end" sideOffset={8}>
{/* Header: name + email (no avatar in dropdown) */}
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{displayName}</p>
<p className="text-xs leading-none text-muted-foreground">{displayEmail}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push('/settings')}>
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/settings')}>
<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 |
|---|---|
| Trigger | variant="ghost" h-9 w-9 rounded-lg p-0 button |
| Avatar | h-8 w-8, fallback bg-primary text-xs font-medium text-primary-foreground |
| Content width | w-56 |
| Align | end |
| Side offset | sideOffset={8} |
| Header | Name (text-sm font-medium) + Email (text-xs text-muted-foreground) |
| Base items | Profile (User icon), Settings (Settings icon), separator, Log out (LogOut icon) |
Note: Some app implementations extend this base with additional items (Billing, Notifications, New Team, keyboard shortcuts). The base three items (Profile, Settings, Log out) are the minimum required.
⚠️ TWO USER ACCESS POINTS: Users can access profile/settings/logout from BOTH:
- Header
UserMenu(top-right of screen) — always visible - Sidebar
SidebarUsercard (bottom of sidebar, Orchestrate only) — see03-SIDEBAR-NAVIGATION.md
Both menus provide Profile, Settings, and Log out at minimum.
Accelerate Module Header (Simplified Variant)
The Accelerate modules use a simplified header since the logo is in the sidebar:
// Mobile only (lg:hidden)
<header className="lg:hidden flex items-center justify-between px-4 py-3 border-b bg-card">
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open menu">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<span className="font-semibold text-lg">{APP_NAME}</span>
<NotificationBell />
</header>
On desktop, the Accelerate modules integrate the notification bell into the sidebar header:
// Sidebar header area (desktop)
<div className="px-6 py-5 border-b">
<div className="flex items-center gap-3">
<AppLogoIcon />
<div>
<p className="text-base font-semibold">{APP_NAME}</p>
<p className="text-xs text-muted-foreground">{APP_RELEASE_VERSION}</p>
</div>
<div className="ml-auto">
<NotificationBell />
</div>
</div>
</div>
Notification Bell
The notification bell is shared across all apps and follows one standard implementation:
// Standard notification bell:
// - Polls every 30 seconds
// - Shows unread count badge (red circle, top-right of bell icon)
// - Badge text: count (99+ if > 99)
// - Click opens dropdown: max-h-80, overflow-y-auto, w-80
// - Each item: title (text-sm font-medium) + body (text-xs text-muted-foreground) + relative time
<button
className="relative p-2 rounded-lg text-muted-foreground hover:bg-muted transition-colors"
aria-label={`Notifications (${count} unread)`}
>
<Bell className="w-5 h-5" />
{count > 0 && (
<span className="absolute top-1 right-1 min-w-[16px] h-4 px-0.5 bg-destructive text-destructive-foreground text-[10px] font-bold rounded-full flex items-center justify-center">
{count > 99 ? '99+' : count}
</span>
)}
</button>
What Goes IN the Header vs NOT
| Lives IN header | Lives OUTSIDE header |
|---|---|
| Page title + subtitle | Breadcrumbs (not used in our system) |
| Global action icons | Page-specific action buttons |
| User avatar/menu | Tab navigation |
| Notification bell | Stats cards |
| Search (global) | Toolbar filters |
| Company logo (tenant) | Sidebar navigation |
| App/product logo (lives in sidebar) |
Violation Checklist
- Header is inside
SidebarInset— NOT a sibling ofAppSidebar - Header does NOT span the sidebar — right column only
- Header uses
sticky top-0 z-50 h-16 border-b bg-background - Page title is
text-lg font-semiboldwithtruncate - Subtitle is
text-sm text-muted-foregroundwithtruncate - No hardcoded page titles in page components (use central map or constants)
- Icon buttons are
variant="ghost" size="icon" h-9 w-9 - Icons are
h-4 w-4(Lucide only) - User avatar uses
bg-primary text-primary-foregroundfallback - Notification badge uses
bg-destructive text-destructive-foreground - Right zone icons appear in standard order (Search → Language → Notifications → Helpdesk → TenantFilter → Anna → ModeToggle → Separator → UserMenu)
- Header has
justify-between(not flex-start or flex-end) -
UserMenutrigger isvariant="ghost" h-9 w-9 rounded-lg p-0 -
DropdownMenuContentisw-56 align="end" sideOffset={8} - UserMenu dropdown header shows name (
text-sm font-medium) + email (text-xs text-muted-foreground) — no avatar in the dropdown header - TWO user access points documented: Header
UserMenu+ SidebarSidebarUser - Center zone logo is the TENANT's logo, not the Anshin app logo
- See
16-COMMAND-PALETTE.mdfor ⌘K integration with Search button