Skip to main content

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"
>
PropertyValueReason
sticky top-0Always visible on scrollContent scrolls under it
z-50Above all page contentDropdowns, tooltips included
h-1664px exactlyNon-negotiable across all apps
border-b1px bottom borderVisual zone separation
bg-backgroundTheme-awareWorks in dark/light mode
px-416px horizontal paddingBreathing room
justify-between3-zone layoutLeft, 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

PropertySpec
Font sizetext-lg (18px)
Font weightfont-semibold
Line heightleading-none
Trackingtracking-tight
Overflowtruncate (never wrap)
Colortext-foreground (default, no color class needed)

Subtitle Rules

PropertySpec
Font sizetext-sm (14px)
Colortext-muted-foreground
Line heightleading-none
Margin topmt-0.5 (2px above)
Overflowtruncate
ContentDescribes 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:

PositionIconComponentShows When
1SearchSearch h-5 w-5 ghost buttonshowSearch={true}
2Language selectorLanguageSelectorshowLanguageSelector={true}
3NotificationsnotificationSlot (injected)showNotifications={true}
4Help / HelpdeskHeadphones h-5 w-5 ghost buttonshowHelpdesk={true}
5Tenant filtertenantFilterSlot (injected)when slot provided
6Anna AI buttonAnnaButtonshowAnna={true}
7Dark mode toggleModeToggleAlways
8SeparatorSeparator orientation="vertical" mx-1 h-6Always
9User avatar / accountUserMenuAlways

Source: packages/shared/components/layouts/header-sections.tsxHeaderRight 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>
PropertyValue
Triggervariant="ghost" h-9 w-9 rounded-lg p-0 button
Avatarh-8 w-8, fallback bg-primary text-xs font-medium text-primary-foreground
Content widthw-56
Alignend
Side offsetsideOffset={8}
HeaderName (text-sm font-medium) + Email (text-xs text-muted-foreground)
Base itemsProfile (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:

  1. Header UserMenu (top-right of screen) — always visible
  2. Sidebar SidebarUser card (bottom of sidebar, Orchestrate only) — see 03-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 headerLives OUTSIDE header
Page title + subtitleBreadcrumbs (not used in our system)
Global action iconsPage-specific action buttons
User avatar/menuTab navigation
Notification bellStats 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 of AppSidebar
  • 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-semibold with truncate
  • Subtitle is text-sm text-muted-foreground with truncate
  • 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-foreground fallback
  • 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)
  • UserMenu trigger is variant="ghost" h-9 w-9 rounded-lg p-0
  • DropdownMenuContent is w-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 + Sidebar SidebarUser
  • Center zone logo is the TENANT's logo, not the Anshin app logo
  • See 16-COMMAND-PALETTE.md for ⌘K integration with Search button