Header Switcher
Vercel-style global top bar: logo then organization / project / environment switchers separated by slashes (production badge in red), with dropdown lists, help, notification dot and avatar.
minimal/layout/header-switcherSource
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Bell, Check, ChevronsUpDown, HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
export interface HeaderSwitcherOption {
id: string;
label: string;
hint?: string;
}
export interface HeaderSwitcherProps {
orgs?: HeaderSwitcherOption[];
projects?: HeaderSwitcherOption[];
environments?: HeaderSwitcherOption[];
defaultOrg?: string;
defaultProject?: string;
defaultEnvironment?: string;
onChange?: (value: { org: string; project: string; environment: string }) => void;
user?: { initials: string; name: string };
notifications?: number;
className?: string;
}
function Switcher({
label,
options,
value,
onChange,
badge,
}: {
label: string;
options: HeaderSwitcherOption[];
value: string;
onChange: (id: string) => void;
badge?: boolean;
}) {
const [open, setOpen] = useState(false);
const id = useId();
const root = useRef<HTMLDivElement>(null);
const current = options.find((o) => o.id === value) ?? options[0];
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => root.current && !root.current.contains(e.target as Node) && setOpen(false);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={root} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={id}
aria-label={`${label}: ${current?.label}`}
onClick={() => setOpen((o) => !o)}
className="da-focus da-transition flex h-8 items-center gap-1.5 rounded-da-md px-2 text-[13px] font-medium hover:bg-da-muted"
>
{badge ? (
<span
className={cn(
"rounded-da-sm px-1.5 py-0.5 font-da-mono text-[11px]",
current?.id === "production" ? "bg-da-danger/10 text-da-danger" : "bg-da-accent text-da-accent-fg",
)}
>
{current?.label}
</span>
) : (
current?.label
)}
<ChevronsUpDown aria-hidden className="size-3.5 text-da-muted-fg" />
</button>
{open && (
<ul
id={id}
role="listbox"
aria-label={label}
className="da-stroke absolute top-full left-0 z-30 mt-1 w-56 rounded-da-lg bg-da-surface p-1 text-da-surface-fg shadow-da-lg transition-[opacity,translate] duration-(--da-duration) starting:-translate-y-1 starting:opacity-0"
>
{options.map((o) => (
<li key={o.id} role="option" aria-selected={o.id === value}>
<button
type="button"
onClick={() => {
onChange(o.id);
setOpen(false);
}}
className="da-focus flex w-full items-center gap-2 rounded-da-sm px-2 py-1.5 text-left text-[13px] hover:bg-da-muted"
>
<span className="min-w-0 flex-1">
<span className="block truncate">{o.label}</span>
{o.hint && <span className="block text-[11px] text-da-muted-fg">{o.hint}</span>}
</span>
{o.id === value && <Check aria-hidden className="size-3.5 text-da-accent-fg" />}
</button>
</li>
))}
</ul>
)}
</div>
);
}
const Slash = () => (
<svg aria-hidden viewBox="0 0 24 24" className="size-4 shrink-0 text-da-border-strong" fill="none">
<path d="M16 3 8 21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
/** Vercel-style global top bar: org / project / environment switchers separated by slashes, help, notifications and avatar. */
export function HeaderSwitcher({
orgs = [
{ id: "acme", label: "Acme Inc.", hint: "Business plan" },
{ id: "side", label: "Maya’s sandbox", hint: "Free plan" },
],
projects = [
{ id: "payments", label: "payments", hint: "12 services" },
{ id: "platform", label: "platform", hint: "8 services" },
{ id: "growth", label: "growth", hint: "5 services" },
],
environments = [
{ id: "production", label: "production" },
{ id: "staging", label: "staging" },
{ id: "preview", label: "preview" },
],
defaultOrg = "acme",
defaultProject = "payments",
defaultEnvironment = "production",
onChange,
user = { initials: "MC", name: "Maya Chen" },
notifications = 3,
className,
}: HeaderSwitcherProps) {
const [value, setValue] = useState({ org: defaultOrg, project: defaultProject, environment: defaultEnvironment });
const update = (patch: Partial<typeof value>) => {
const next = { ...value, ...patch };
setValue(next);
onChange?.(next);
};
return (
<header className={cn("flex h-12 items-center gap-1 border-b border-da-border bg-da-bg px-3 text-da-fg", className)}>
<svg aria-hidden viewBox="0 0 24 24" className="mr-1 size-5 shrink-0 text-da-fg" fill="none">
<path d="M3 12h4l2.5-6 5 12 2.5-6H21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<Slash />
<nav aria-label="Context" className="flex min-w-0 items-center gap-1">
<div className="hidden sm:block">
<Switcher label="Organization" options={orgs} value={value.org} onChange={(org) => update({ org })} />
</div>
<span className="hidden sm:block">
<Slash />
</span>
<Switcher label="Project" options={projects} value={value.project} onChange={(project) => update({ project })} />
<Slash />
<Switcher label="Environment" options={environments} value={value.environment} onChange={(environment) => update({ environment })} badge />
</nav>
<div className="ml-auto flex items-center gap-1">
<a href="#help" aria-label="Help" className="da-focus grid size-8 place-items-center rounded-da-md text-da-muted-fg hover:bg-da-muted hover:text-da-fg">
<HelpCircle aria-hidden className="size-4" />
</a>
<a href="#notifications" className="da-focus relative grid size-8 place-items-center rounded-da-md text-da-muted-fg hover:bg-da-muted hover:text-da-fg">
<Bell aria-hidden className="size-4" />
<span className="sr-only">Notifications ({notifications} unread)</span>
{notifications > 0 && <span aria-hidden className="absolute top-1.5 right-1.5 size-2 rounded-full bg-da-primary ring-2 ring-da-bg" />}
</a>
<span
aria-label={user.name}
role="img"
className="ml-1 grid size-7 place-items-center rounded-full bg-da-accent text-[11px] font-medium text-da-accent-fg"
>
{user.initials}
</span>
</div>
</header>
);
}
export default HeaderSwitcher;
modules/minimal/layout/header-switcher/index.tsx
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| orgs / projects / environments | { id, label, hint? }[] | — | Options. |
| defaultOrg / defaultProject / defaultEnvironment | string | — | Initial ids. |
| onChange | ({ org, project, environment }) => void | — | Change handler. |
| user | { initials, name } | — | Avatar. |
| notifications | number | 3 | Unread count. |
| className | string | — | Classes. |
Other app header variants in Minimal
App Header
Two-row app header: breadcrumbs, ⌘K search trigger and notification dot on top; view tabs with Filter / Display actions below. Includes a working command palette (Radix Dialog) with grouped, filterable, keyboard-navigable commands.
Header Detail
Detail-page header: breadcrumbs ending on a copyable mono id, title with status pill, a key/value meta row, action buttons and underline section tabs with counts.
Header Toolbar
List-page toolbar: title with count and New button, then a search field, removable key/value filter chips, an Add filter button and a list/board view toggle.
App header in other art directions
App Header
Two-tier app header: top bar with breadcrumbs, ⌘K search field, notification bell with counter and avatar; page band with big title, meta line, primary action and folder-style tabs.
Header Compact
Dense 56px app header built from ruled cells: title with count chip, view tabs (yellow active cell), filter search, ink 'New' cell, and an optional strip of active filter chips.
Header Editor
Document/editor header: back cell, version chip, inline-editable uppercase title, save-status chip (saved/saving/unsaved), Preview cell and a yellow Publish cell; actions wrap to a full-width bar on mobile.
Header Stats
Dashboard welcome header: yellow band with eyebrow, big greeting, status sentence and two pressable actions, over a ruled KPI strip (2×2 on mobile, 4 across on desktop) with green delta chips.
App Header
Floating frosted pill top bar (search with ⌘K hint, gradient notification counter, avatar menu button) above a page heading with a frosted segmented tab switcher and a gradient primary action.
Header Document
Sticky glass editor header: breadcrumbs, inline-editable document title, saved/editing indicator, live viewers with green presence rings, gradient Share button and more menu.
Header Greeting
Dashboard header with a time-of-day greeting and day summary, notification bell with gradient counter, a large glass “Ask Halo” search bar with gradient submit and quick-action chips.
Header Meeting
In-call glass header pill: pulsing REC timer, meeting title, “Halo is taking notes” gradient chip, participant avatars, grid/speaker layout toggle, translate toggle and a red Leave button.
App Header
Path-style app header: slash-separated breadcrumb “Nord Studio / Projects / Oslo Opera” (last segment ink), live status with signal dot and last-edit time in mono, outline “Preview” and ink “Publish”.
Header Editor
Editor top bar in three zones: back arrow + editable page title, a centered square device switcher (desktop/tablet/mobile, radio semantics), and undo/redo text actions + save state + ink Publish.
Header Tabs
Settings-style header: title + domain subtitle, then a row of text tabs with an ink underline on the active one sitting on the hairline; scrolls horizontally on mobile.
Header Title
Big-title page header: a huge display title with a superscript mono count, then a ruled toolbar with text filters (slash separated, active underlined in signal) on the left and view options + ink action on the right.
App Header
Page header for app screens: breadcrumb trail, title + description, right-aligned secondary/primary actions and an optional row of key-value meta; white with a bottom border.
Header Global
Global top bar: logo, entity switcher (Radix dropdown), centered search with ⌘K hint, help + notifications (count) icon buttons and a user avatar menu (profile, settings, sign out).
Header Report
Report header: title with “last updated” + refresh, a toolbar with period segmented control, entity select, compare toggle and Export; wraps on mobile.
Header Tabs
Record header (e.g. a customer): avatar initials, name + status pill, subtitle, action buttons, and an underline tab bar (links with aria-current and counts) that scrolls horizontally on mobile.
App Header
Friendly app header: time-of-day serif greeting with the user’s name in italic sage, a calm subtitle, a rounded search field, bell with soft dot and an initials avatar.
Header Period
Report header: serif title with an italic period word that updates, a pill period switcher (radio semantics) and a soft export button; a wavy divider underneath.
Header Progress
Page header with a target ring: soft breadcrumb, serif title and description, and on the right a circular progress ring (sage stroke on sand) with the percentage and target label.
Header Tabs
Section header with pill tabs: serif title and a sage action on one row, then a sand track of pill links (active one cream with shadow, counts in small circles) that scrolls on mobile.
App Header
Friendly top header: “Good morning, Ana 👋” with a day summary; search pill, bell with red badge, periwinkle “New” button and emoji avatar on the right.
Header Board
Board page header: emoji tile, title and description, member emoji avatars, ink Share pill, then a pill view switcher (Board, List, Week) and a filter button.
Header Search
Search-first header: wide rounded search field with clear button, and emoji filter chips (Assigned to me, Due soon, Overdue, Done) that toggle to ink below.
Header Week
Week-planner header: round prev/next arrows around the week range (announced), a lavender “Today” pill and a mint progress pill with emoji and mini bar.