UI Library

Site header

A sticky marketing header with a translucent blur, inline nav from the md breakpoint up, and a real disclosure menu below it. The mobile panel is toggled by a button that reports its own state, not a bare icon.

Navigationheadernavstickyresponsive
apps/site/blocks/components/SiteHeader.tsx
'use client';

import { useState } from 'react';
import { Menu, X } from 'lucide-react';

export interface NavLink {
  label: string;
  href: string;
}

const DEFAULT_LINKS: NavLink[] = [
  { label: 'Product', href: '#' },
  { label: 'Pricing', href: '#' },
  { label: 'Docs', href: '#' },
  { label: 'About', href: '#' },
];

export function SiteHeader({
  brand = 'Acme',
  links = DEFAULT_LINKS,
  ctaLabel = 'Get started',
  ctaHref = '#',
}: {
  brand?: string;
  links?: NavLink[];
  ctaLabel?: string;
  ctaHref?: string;
}) {
  const [open, setOpen] = useState(false);

  return (
    <header className="sticky top-0 z-40 w-full border-b border-border/60 bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
      <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
        <a href="#" className="flex items-center gap-2 text-sm font-semibold tracking-tight">
          <span className="flex h-6 w-6 items-center justify-center rounded bg-primary text-xs font-bold text-primary-foreground">
            {brand.charAt(0)}
          </span>
          {brand}
        </a>

        <nav aria-label="Main" className="hidden items-center gap-6 text-sm text-muted-foreground md:flex">
          {links.map((link) => (
            <a key={link.label} href={link.href} className="transition-colors hover:text-foreground">
              {link.label}
            </a>
          ))}
        </nav>

        <div className="hidden md:block">
          <a
            href={ctaHref}
            className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
          >
            {ctaLabel}
          </a>
        </div>

        {/* aria-expanded + aria-controls, so the button announces what it does
            and what it owns — an unlabelled icon button tells a screen reader
            nothing. */}
        <button
          type="button"
          aria-expanded={open}
          aria-controls="site-header-mobile"
          aria-label={open ? 'Close menu' : 'Open menu'}
          onClick={() => setOpen((o) => !o)}
          className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground md:hidden"
        >
          {open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
        </button>
      </div>

      {open ? (
        <div id="site-header-mobile" className="border-t border-border/60 md:hidden">
          <nav aria-label="Main" className="mx-auto max-w-6xl space-y-1 px-4 py-3 sm:px-6">
            {links.map((link) => (
              <a
                key={link.label}
                href={link.href}
                onClick={() => setOpen(false)}
                className="block rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
              >
                {link.label}
              </a>
            ))}
            <a
              href={ctaHref}
              className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground"
            >
              {ctaLabel}
            </a>
          </nav>
        </div>
      ) : null}
    </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.