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
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.