PVR Tech Studio

Header

The single h-16 top bar of the Luminaux shell — an optional mega-nav on the left and a right-cluster of icon controls (search, design-system, apps, language, fullscreen, settings, theme, notifications, profile).

11 min read
Updated July 15, 2026

Overview

The header is a single h-16 row rendered by src/layout/Header.tsx. It is mounted once by AppLayout and stays fixed above the routed content. It has two regions:

  • Left — the mobile menu button (drawer toggle, lg:hidden), an optional brand lockup (only in header-only mode), and the primary navigation HeaderMegaNav (gated by config.showPrimaryNav, lg:flex). This is not the sidebar tree — it is a curated set of showcase mega-menus.
  • Right cluster (ml-auto, shrink-0) — a fixed order of controls: search (⌘K) · design-system · apps · language · fullscreen · settings · theme · bell · profile.

The whole bar can auto-hide on scroll-down / reveal on scroll-up when config.autoHideNav is on (useScroll + useMotionValueEvent), and the right cluster animates in with a staggered entrance (headerStagger / headerStaggerItem from src/lib/motion.ts).

The header does not own any global state of its own — it is a presentational bar wired to shared stores (useTheme, useLayout, useFullscreen, usePresence, useLanguage) and to the overlay open-state that lives in AppLayout.

Architecture & files

FileResponsibility
src/layout/Header.tsxThe h-16 bar; left region + right cluster; auto-hide scroll logic.
src/layout/AppLayout.tsxMounts the header, owns overlay open-state (searchOpen / customizerOpen / notificationsOpen), binds ⌘K, feeds useNotifications() into the header + panel, pushes visited routes via pushRoute.
src/layout/mega/HeaderMegaNav.tsxDesktop primary nav — one shared morphing dropdown across the four menu triggers.
src/layout/mega/MegaPanels.tsxThe panel bodies: QuickAccessPanel, FlyoutMenuPanel, MegaMenuPanel, MultilevelPanel.
src/layout/mega/MegaMobileNav.tsxMobile fallback — the showcase menus as accordions inside the sidebar drawer.
src/data/megaMenu.tsData for the mega menus (quickAccess, resourcesMenu, megaTabs, footer links).
src/layout/overlays/SearchOverlay.tsxThe ⌘K command palette.
src/hooks/useRecentRoutes.tsRecently-visited routes store feeding the palette's "Recent" group.
src/layout/AppsMenu.tsx + src/data/apps.tsThe "waffle" app launcher grid.
src/layout/DesignSystemMenu.tsxThe skin switcher with live per-skin colour swatches.
src/layout/LanguageMenu.tsx + src/hooks/useLanguage.tsThe language dropdown.
src/hooks/useFullscreen.tsBrowser fullscreen toggle.
src/layout/ProfileMenu.tsx + src/data/user.ts + src/hooks/usePresence.tsAvatar dropdown: identity, account links, presence switcher, sign-out.
src/layout/overlays/NotificationsPanel.tsx + src/hooks/useNotifications.ts + src/data/notifications.tsThe notifications feed + right slide-in panel.
src/lib/accent.tsaccentTile — categorical accent → literal token classes, used by AppsMenu.

The right cluster (source order)

From Header.tsx, each control is wrapped in a motion.div variants={headerStaggerItem} for the cascade:

  1. Search — a ghost Button (onSearchClick) with a Search icon and a ⌘K <kbd> chip (lg:inline).
  2. DesignSystemMenuhidden sm:block.
  3. AppsMenuhidden sm:block.
  4. LanguageMenu — always shown.
  5. Fullscreen — icon Button calling toggleFullscreen, hidden lg:block.
  6. Settings — icon Button (onCustomizerClick) with Settings2; opens the Layout Customizer.
  7. Theme — icon Button calling toggleTheme; the Sun/Moon icon crossfades with an AnimatePresence rotate.
  8. Bell — icon Button (onNotificationsClick) with a pulsing unreadCount badge (9+ cap).
  9. ProfileMenu — the avatar trigger + dropdown.

Usage

The header is mounted by AppLayout; you don't render it directly in pages. AppLayout passes the callbacks and the unread count:

// src/layout/AppLayout.tsx (excerpt)
const {notifications, unreadCount, markRead, markAllRead, resolve} = useNotifications()
 
<Header
    onMenuClick={() => setDrawerOpen(true)}
    onCustomizerClick={() => setCustomizerOpen(true)}
    onSearchClick={() => setSearchOpen(true)}
    onNotificationsClick={() => setNotificationsOpen(true)}
    unreadCount={unreadCount}
/>

⌘K (or Ctrl-K) toggles the search overlay from anywhere, bound in AppLayout:

// Global ⌘K / Ctrl-K
useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
        if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
            e.preventDefault()
            setSearchOpen((o) => !o)
        }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
}, [])

API / Props

Header props (HeaderProps)

PropTypeDescription
onMenuClick() => voidOpens the mobile sidebar drawer.
onCustomizerClick() => voidOpens the Layout Customizer slide-in.
onSearchClick() => voidOpens the ⌘K search overlay.
onNotificationsClick() => voidOpens the notifications panel.
unreadCountnumberDrives the bell badge (hidden when 0, capped at 9+).

Controls at a glance

ControlComponentWhat it doesKey hook/data
Mobile menuinline <button>Opens the sidebar drawer (lg:hidden).onMenuClick
Primary navHeaderMegaNavFour showcase mega-menus. Gated by config.showPrimaryNav (always on in header-only).src/data/megaMenu.ts
Searchghost ButtonSearchOverlayCommand palette; also ⌘K.useRecentRoutes, menu, useLayout, useTheme
Design systemDesignSystemMenuSwitches the visual skin live, with swatches + toast.useLayout().setSkin, SKINS
AppsAppsMenuWaffle launcher grid; navigates or toasts a stub.src/data/apps.ts, accentTile
LanguageLanguageMenuPicks the UI language; trigger shows the active flag.useLanguage
Fullscreenicon ButtonToggles browser fullscreen.useFullscreen
Settingsicon ButtonOpens the Layout Customizer.onCustomizerClick
Themeicon ButtonToggles light/dark.useTheme().toggleTheme
Notificationsicon ButtonNotificationsPanelFeed with filter tabs + request approve/reject.useNotifications
ProfileProfileMenuIdentity, account links, presence, sign-out.currentUser, usePresence

Configuration & customization

Header presentation flags

The header reads useLayout().config and useRouteLayout():

  • config.showPrimaryNav — show/hide the HeaderMegaNav triggers (still always shown when header-only).
  • config.autoHideNav — hide on scroll-down, reveal on scroll-up. Mutually exclusive with stickyPageHeader (a hidden header would leave a gap above a stuck page header).
  • config.fixedNav — keeps the header pinned to the top (structural, handled by the shell SCSS).
  • config.headerOnly or useRouteLayout().forceHeaderOnly — hides the sidebar and surfaces the brand lockup in the header (from src/config/brand.ts), and forces the mega-nav on. The /apps/chat route forces header-only presentation via its appRoutes entry.

See Customizer & Layout Settings for how these flags are toggled, and Layout & Shell for how the shell consumes them.

Mega navigation (HeaderMegaNav)

HeaderMegaNav is a single shared dropdown modelled on the Motion mega-menu example: one panel that morphs size/position between triggers (a layout tween, fast rather than a spring to minimise text distortion), with a sliding layoutId="mega-hl" highlight under the hovered/active trigger and a content crossfade. It opens on hover and keyboard focus, closes on Escape and on mouse-leave (with a 140ms grace timer). The trigger row is horizontally scrollable (scrollbar hidden) so the nav never has to hide on narrow widths; the panel is a sibling of the scroll row so it isn't clipped.

The four menus come from MENUS in HeaderMegaNav.tsx, each rendering a panel from MegaPanels.tsx:

KeyLabel key (nav:)PanelSource data
quickmmQuickAccessQuickAccessPanelquickAccess (6 dashboards) + quickAccessFooter
resourcesmmResourcesFlyoutMenuPanelresourcesMenu (6 categories × 4 items)
megammMegaMenuMegaMenuPanel (align: 'start')megaTabs (UI / Pages / Components, 9 each)
multilevelmultilevelMultilevelPanelmirrors the full sidebar menu

Wide panels use align: 'start' to pin to the nav's left edge; the others clamp under their trigger within an 8px viewport gutter. Item labels reuse the sidebar's nav: keys; descriptions/headings are mega-specific nav: keys (mm-prefixed). See src/data/megaMenu.ts for the full tables and the Accent type ('primary' | 'success' | 'warning' | 'danger' | 'info') used by card tiles.

⌘K command palette (SearchOverlay)

The palette builds its command set from the app's real data:

  • Recent — recently-visited routes from useRecentRoutes (a useSyncExternalStore module store, most-recent-first, de-duped, capped at 6, persisted to localStorage('recent-routes')). AppLayout calls pushRoute(location.pathname) on every navigation.
  • Navigation — every leaf in src/data/menu.ts (recursively walked) → navigate(to).
  • Appearance — a theme toggle plus one entry per skin in SKINS (setSkin).
  • Layout — toggle minified sidebar / dual sidebar / fixed header (update({...})).

It filters by label + keywords, groups in a fixed order (Recent → Navigation → Appearance → Layout), supports full keyboard nav (ArrowUp/Down, Enter, Escape), a layoutId="cmd-active" sliding highlight, and a Typewriter hint on the empty state. Text lives in the search namespace.

Apps launcher (AppsMenu)

A "waffle" grid of apps (src/data/apps.ts). Each tile's coloured icon uses accentTile[app.accent] (src/lib/accent.ts) — literal token classes because Tailwind only emits class strings it sees literally. Tiles with a to navigate; tiles without one fire an info toast (appStub). Labels resolve in the header namespace. The panel pops via a shared gentle spring (MotionConfig) with a per-tile scale-in stagger; dismissal uses useDismiss.

Design-system (skin) switcher (DesignSystemMenu)

A Palette-icon dropdown that swaps the visual skin live. Each option renders a real per-skin swatch inside a data-skin={id} scope, so the swatch pulls that skin's actual --primary / --success / --warning / --surface-muted tokens. Picking a skin calls setSkin(id) and fires a success toast. See Design Skins for the skin mechanism.

Language switcher (LanguageMenu)

The trigger shows the active language flag; the dropdown is a two-column grid of flag + native name (+ English label when different). Selecting calls useLanguage().setLanguage(code), which persists via the i18next detector. See Internationalization.

Fullscreen (useFullscreen)

Toggles document.documentElement.requestFullscreen() / exitFullscreen() and tracks state via the fullscreenchange event (so Esc / F11 exits update the icon). Hidden below lg.

Profile menu (ProfileMenu)

The avatar trigger shows the user's avatar with a presence StatusDot, plus name + tagline (t(currentUser.roleKey)) on sm:flex. The panel (origin-top-right popUp, sections cascade in) has:

  • Identity — avatar, name, role, email (from currentUser in src/data/user.ts).
  • Account links — Profile / Account Settings / Billing / Help (stubbed) + a Keyboard Shortcuts row that dispatches the open-shortcuts window event.
  • Presence switcheronline / away / busy / offline via the shared usePresence store (localStorage('user-status')), keeping the sidebar user-card in sync.
  • Sign out — removes auth_token (API_CONFIG.tokenKey), toasts, and navigates to /auth/login.

Notifications panel (NotificationsPanel)

A right slide-in (same pattern as the customizer). useNotifications owns the feed + read state (seeded from src/data/notifications.ts, text stored as i18n keys in the notifications namespace). Features: filter tabs (all / unread / mentions), per-type icon + tint (typeMeta), swipe-to-mark-read rows (SwipeRow), approve/reject actions on request-type rows (resolve), an empty state, and a Mark all read footer. The bell badge (unreadCount) pulses when > 0.

Examples

Force header-only presentation on a route

Header-only is presentation-only — set the flag on the route's appRoutes entry (src/routes.tsx) rather than mutating the persisted config:

// src/routes.tsx
{
    path: '/apps/chat',
    element: <ChatPage />,
    lazy: false,
    fullBleed: true,
    headerOnly: true,   // hides the sidebar; the header shows the brand + mega-nav
    fixedFooter: true,
}

Header.tsx reads this via useRouteLayout().forceHeaderOnly and mirrors the sidebar-less branches.

Add an app to the waffle launcher

// src/data/apps.ts
export const apps: AppLink[] = [
    // ...
    {key: 'appReports', to: '/reports', icon: FileBarChart, accent: 'info'},
]
// then add `header:appReports` to src/locales/en/header.json

Add a mega-nav Quick Access card

// src/data/megaMenu.ts
export const quickAccess: MegaLink[] = [
    // ...
    {
        key: 'salesDashboard',
        labelKey: 'salesDashboard',   // nav:salesDashboard
        descKey: 'qaSalesDesc',       // nav:qaSalesDesc
        to: '/dashboards/sales',
        icon: TrendingUp,
        accent: 'success',
    },
]

Best practices

  • Keep the right cluster order stable. Users learn the icon positions; reorder only with intent.
  • Never hardcode colours. Icon tiles use accentTile (token classes); the whole bar uses semantic tokens (bg-surface/80, text-muted-foreground, border-border). See Design Tokens & Dark Mode.
  • Never hardcode user-facing text. All labels resolve through i18n (header, nav, search, notifications namespaces). See Internationalization.
  • Don't put the sidebar tree in the header. HeaderMegaNav is a curated showcase; the full tree lives in the sidebar (src/data/menu.ts). See Sidebar & Navigation.
  • Prefer the shared stores. useTheme, usePresence, useLayout are module-level stores so the header, customizer, and Layout Settings stay in sync — don't fork them to local useState.
  • Gate desktop-only affordances with the existing responsive classes (hidden sm:block, hidden lg:block) rather than removing controls.

Troubleshooting

SymptomLikely causeFix
Mega-nav triggers don't appearconfig.showPrimaryNav is off and the route isn't header-onlyEnable "Show primary nav" in the Customizer, or set headerOnly on the route.
⌘K does nothingThe keydown listener lives in AppLayout — a modal may be swallowing the eventConfirm you're inside AppLayout (not /auth/*); check no other handler preventDefaults ⌘K first.
"Recent" group is empty in the paletteNo routes visited yet this session/historypushRoute records on navigation; navigate a few pages. Persisted in localStorage('recent-routes').
Skin swatch shows the wrong coloursMissing data-skin scope around the swatchSwatches must be wrapped in data-skin={id} to re-scope tokens (see DesignSystemMenu).
Header won't auto-hideautoHideNav off, or stickyPageHeader onThey're mutually exclusive; enabling auto-hide clears sticky page header (and vice-versa).
Bell badge stuckunreadCount derived from useNotifications stateMarking read updates the store; the seed is in-memory (resets on reload).
Sign-out doesn't clear sessionToken key mismatchSign-out removes API_CONFIG.tokenKey; ensure your auth uses the same auth_token key.

FAQ

Is the header nav the same as the sidebar? No. The sidebar (src/data/menu.ts) is the full app tree; the header's HeaderMegaNav is a separate curated showcase (src/data/megaMenu.ts), kept in sync by convention.

Where does the brand name come from? Only from src/config/brand.ts (nameLead / nameAccent). The header brand lockup only renders in header-only mode.

How do I change the signed-in user shown in the profile menu? Edit currentUser in src/data/user.ts (mock display data until a real auth/user context lands). Presence overrides persist to localStorage('user-status').

Can I move an overlay's open-state into the header? Overlay open-state deliberately lives in AppLayout so ⌘K and the panels survive across the header's own re-renders. Keep it there.

Why is <Toaster/> not in the header? Toasts are mounted at the root in main.tsx so they survive route changes (e.g. sign-out → /auth/login).

Notes for designers & content editors

  • Labels are i18n keys, not literals. Header controls read the header namespace; nav/mega labels read nav; palette text reads search; the bell feed reads notifications. Edit src/locales/en/<ns>.json and translate via the i18n tooling.
  • Icons are lucide-react. Swap the icon in the relevant data file (apps.ts, megaMenu.ts) or component; keep the same visual weight (h-5 w-5 in the cluster).
  • Colours come from tokens/skins, not hex. To recolour an accent tile, change its accent value (one of the five semantic tones), not a class.
  • Flags are SVGs in public/flags/<code>.svg; the active flag drives the language trigger.
  • Avatars are self-hosted in public/avatars/. The profile uses /avatars/avatar-default.png by default.

Was this page helpful?