UI Library
App header
The authenticated-product header: breadcrumb trail, a search field bound to the / hotkey, a notification bell with an unread count, and an avatar. The hotkey stands down while you are already typing somewhere else.
Navigationheaderappsearchbreadcrumb
Preview
/
apps/site/blocks/components/AppHeader.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import { Bell, Search, Slash } from 'lucide-react';
export function AppHeader({
crumbs = ['Acme', 'Website redesign'],
unread = 3,
initials = 'FJ',
}: {
crumbs?: string[];
unread?: number;
initials?: string;
}) {
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key !== '/') return;
// Never steal the keystroke from a field the user is already in —
// otherwise typing "and/or" in a comment yanks focus to search.
const el = document.activeElement;
const tag = el?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement)?.isContentEditable) return;
event.preventDefault();
searchRef.current?.focus();
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
return (
<header className="flex h-14 w-full items-center gap-3 border-b border-border bg-background px-4 sm:px-6">
<nav aria-label="Breadcrumb" className="hidden min-w-0 sm:block">
<ol className="flex items-center gap-1 text-sm">
{crumbs.map((crumb, i) => (
<li key={crumb} className="flex items-center gap-1">
{i > 0 ? <Slash className="h-3 w-3 -rotate-12 text-border" aria-hidden /> : null}
<a
href="#"
// The last crumb is the current page: styled as such and marked
// aria-current, rather than looking like another link.
aria-current={i === crumbs.length - 1 ? 'page' : undefined}
className={
i === crumbs.length - 1
? 'truncate font-medium text-foreground'
: 'truncate text-muted-foreground transition-colors hover:text-foreground'
}
>
{crumb}
</a>
</li>
))}
</ol>
</nav>
<div className="relative ml-auto w-full max-w-xs">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<input
ref={searchRef}
type="search"
placeholder="Search"
aria-label="Search"
className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-8 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
/>
<kbd className="pointer-events-none absolute right-2 top-1/2 hidden -translate-y-1/2 rounded border border-border bg-muted px-1.5 font-mono text-[10px] text-muted-foreground sm:block">
/
</kbd>
</div>
<button
type="button"
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
className="relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Bell className="h-4 w-4" />
{unread > 0 ? (
<span className="absolute right-1.5 top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground">
{unread > 9 ? '9+' : unread}
</span>
) : null}
</button>
<button
type="button"
aria-label="Account menu"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground transition-colors hover:bg-accent"
>
{initials}
</button>
</header>
);
}
Dependencies
lucide-react— npm package
Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.