App Header
Application header: brand, nav with active state, a slot for auth or account controls, and a theme toggle.
npx shadcn@latest add @hextor/app-headerSource
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/registry/hextor/lib/utils";
import {
resolveLabels,
type AppHeaderLabels,
} from "@/registry/hextor/components/hextor/app-header/labels";
import type {
AppHeaderBrand,
AppHeaderNavItem,
} from "@/registry/hextor/components/hextor/app-header/types";
/**
* A reusable application header: brand mark, a nav row, and a slot for
* whatever identity affordance the host app wants (auth buttons, an avatar
* menu, nothing at all). The upstream this is styled after hardcoded all
* three — HextorCo's brand string, HextorCo's nav destinations, and a
* three-way Clerk/dev-session/demo-mode branch for the trailing slot — none
* of which survives contact with a second consumer, so all three arrive as
* props instead.
*/
export function AppHeader({
brand,
nav = [],
activeHref,
actions,
themeToggle = true,
sticky = true,
className,
labels,
}: {
brand: AppHeaderBrand;
nav?: AppHeaderNavItem[];
/**
* Which nav item reads as current. A prop rather than a `usePathname()`
* call: this block ships into whatever router (or router-less shell) hosts
* it, and a hook call here would only work inside the one router it was
* authored against. The caller already knows the active route.
*/
activeHref?: string;
/** Auth buttons, an avatar menu, anything — the header only reserves the slot. */
actions?: ReactNode;
themeToggle?: boolean;
/**
* Pins the header and blurs what scrolls under it. Default on because that is
* what an app header is usually for, but a prop rather than a fixed class
* because it is not free: a sticky header inside an already-scrolling panel
* pins to the wrong ancestor, and `backdrop-blur` forces a compositing layer
* that is wasted on a header nothing scrolls beneath.
*/
sticky?: boolean;
className?: string;
labels?: Partial<AppHeaderLabels>;
}) {
const { resolvedTheme, setTheme } = useTheme();
const copy = resolveLabels(labels);
return (
<header
className={cn(
"flex h-12 shrink-0 items-center gap-3 border-b bg-background px-3",
sticky &&
"sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60",
className
)}
>
<Link href={brand.href} className="flex items-center gap-2">
{brand.icon ? <brand.icon className="size-4" /> : null}
<span className="text-sm font-semibold tracking-tight">{brand.label}</span>
</Link>
{nav.length > 0 && (
<nav className="flex items-center gap-1">
{nav.map((item) => {
const isActive = activeHref === item.href;
const content = (
<>
{item.icon ? <item.icon className="size-3.5" /> : null}
<span className="hidden sm:inline">{item.label}</span>
{item.badge ? (
<Badge variant="outline" className="h-4 px-1 text-[9px] font-normal">
{item.badge}
</Badge>
) : null}
</>
);
return (
<Button
key={item.href}
variant="ghost"
size="sm"
aria-current={isActive ? "page" : undefined}
className={cn("text-muted-foreground", isActive && "text-foreground")}
// Base UI has no `asChild`; `render` takes the replacement element
// directly and that element's own children win over the trigger's.
render={
item.external ? (
<a href={item.href} target="_blank" rel="noreferrer">
{content}
</a>
) : (
<Link href={item.href}>{content}</Link>
)
}
/>
);
})}
</nav>
)}
<div className="ml-auto flex items-center gap-1">
{actions}
{themeToggle && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
{/*
CSS-driven swap, not a mounted-state gate: both icons and both
sr-only names render identically on server and client, and only
the `.dark` class — already on <html> pre-hydration via
next-themes' injected script — decides which half is visible.
`resolvedTheme` is only read inside the click handler, which
never runs during SSR, so there is no prop to mismatch.
*/}
<Sun className="size-4 dark:hidden" aria-hidden />
<Moon className="hidden size-4 dark:block" aria-hidden />
<span className="sr-only dark:hidden">{copy.themeToggle.switchToDark}</span>
<span className="sr-only hidden dark:inline">
{copy.themeToggle.switchToLight}
</span>
</Button>
}
/>
<TooltipContent>
<span className="dark:hidden">{copy.themeToggle.switchToDark}</span>
<span className="hidden dark:inline">{copy.themeToggle.switchToLight}</span>
</TooltipContent>
</Tooltip>
)}
</div>
</header>
);
}
Docs
Everything app-specific arrives as props. activeHref is a prop rather than a usePathname() call on purpose: a block that reaches for a router hook only works inside the router it was written against, and the caller already knows the current route. Auth controls go in actions — the header reserves the slot and has no opinion about what fills it.
The theme toggle swaps icons with Tailwind's dark: variant rather than by reading resolvedTheme during render. next-themes sets the class before hydration, so a JS-driven swap would flash the wrong icon and warn about a hydration mismatch; the accessible name switches the same CSS-only way.
sticky defaults on but is a prop, not a fixed class: a sticky header inside an already-scrolling panel pins to the wrong ancestor, and backdrop-blur forces a compositing layer that is wasted when nothing scrolls beneath.
Renders a <Tooltip>, so a <TooltipProvider> must be mounted somewhere above it — one per app, not one per component.