Skip to content
Caseroom

Blocks · Navigation

Site header

Brand, links and actions, sticky; below 1024px they fold into a Menu panel. Inline or centred.

71% of the 2,062 sites in The UI Index have a header (1,458 sites).

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/site-header.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/site-header.json

Or copy the files below into your project. They need clsx, tailwind-merge and the components they import, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

site-header.classes.ts
/** The site header (ported from the themes' sections/header): brand, up to six links, one or two actions, sticky.
 *  Below 1024px the links and actions fold into a panel behind a Menu toggle (Escape or following a link closes
 *  it). inline puts the links after the brand; centered puts them in the middle. */
export const siteHeader = {
  root: 'relative z-40 bg-background/90 text-foreground backdrop-blur has-[[data-header-menu][data-open]]:z-50',
  sticky: 'sticky top-0',
  skip: 'sr-only rounded-md bg-background text-sm font-medium focus:not-sr-only focus:absolute focus:top-3 focus:left-4 focus:z-50 focus:inline-flex focus:min-h-10 focus:items-center focus:px-4 focus:shadow-lg',
  bar: 'mx-auto flex h-16 max-w-6xl items-center gap-6 px-6',
  brand: '-mx-2 flex h-10 items-center gap-2 rounded-md px-2 text-[15px] font-semibold tracking-[-0.02em] transition-opacity hover:opacity-75 active:opacity-60',
  // centered: the brand's cell takes the space, the link inside it only its word.
  brandCentered: 'lg:flex-1',
  toggle:
    'group ml-auto inline-flex h-10 cursor-pointer items-center gap-2 rounded-md border border-input px-3 text-sm font-medium transition-[background-color,transform] hover:bg-accent active:scale-[0.97] active:bg-pressed aria-expanded:bg-accent lg:hidden [&_svg]:size-4',
  // One menu for both widths: a panel under the bar below 1024px (while data-open), the bar's own row from 1024px.
  menu: 'max-lg:absolute max-lg:inset-x-0 max-lg:top-full max-lg:hidden max-lg:flex-col max-lg:gap-4 max-lg:border-b max-lg:bg-background max-lg:px-6 max-lg:pt-2 max-lg:pb-6 max-lg:shadow-lg max-lg:data-open:flex lg:contents',
  nav: 'lg:ml-2',
  navCentered: 'lg:mx-auto',
  list: 'm-0 flex list-none p-0 max-lg:flex-col lg:items-center lg:gap-1',
  link: 'flex min-h-10 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 max-lg:min-h-12 max-lg:rounded-none max-lg:px-0 max-lg:hover:bg-transparent',
  actions: 'flex gap-2 max-lg:flex-col max-lg:items-stretch lg:ml-auto',
  actionsCentered: 'lg:flex-1 lg:justify-end',
};

export type HeaderLink = { label: string; href: string };
export type HeaderContent = {
  brand: { label: string; href: string };
  links?: HeaderLink[];
  /** At most two: the secondary first (Sign in), the primary last (Start free). */
  actions?: { label: string; href: string; primary?: boolean }[];
  variant?: 'inline' | 'centered';
  currentPath?: string;
  sticky?: boolean;
  /** Unique on the page; ids the menu. */
  id?: string;
};
export const MENU_PATH = 'M2.5 4.5h11M2.5 8h11M2.5 11.5h11';
export const CLOSE_PATH = 'M4 4l8 8M12 4l-8 8';
site-header.client.ts
// Site header behaviour, shared by the Astro, React and HTML versions: the Menu toggle below 1024px. A click (or
// Enter/Space) opens and closes the panel; Escape closes it and returns focus to the toggle; following a link in it
// closes it.
declare global {
  interface Window {
    __caseroomHeader?: boolean;
  }
}

const set = (header: Element, open: boolean, focus = false) => {
  const toggle = header.querySelector<HTMLButtonElement>('[data-header-toggle]');
  const menu = header.querySelector('[data-header-menu]');
  if (!toggle || !menu) return;
  toggle.setAttribute('aria-expanded', String(open));
  menu.toggleAttribute('data-open', open);
  if (focus) toggle.focus();
};

if (typeof window !== 'undefined' && !window.__caseroomHeader) {
  window.__caseroomHeader = true;
  document.addEventListener('click', (e) => {
    const target = e.target as Element;
    const toggle = target.closest('[data-header-toggle]');
    if (toggle) return set(toggle.closest('[data-header]')!, toggle.getAttribute('aria-expanded') !== 'true');
    const link = target.closest('[data-header-menu] a');
    if (link) set(link.closest('[data-header]')!, false);
  });
  document.addEventListener('keydown', (e) => {
    if (e.key !== 'Escape') return;
    document.querySelectorAll('[data-header-toggle][aria-expanded="true"]').forEach((t) => set(t.closest('[data-header]')!, false, true));
  });
}

export {};
site-header.tsx
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import './site-header.client';
import { CLOSE_PATH, MENU_PATH, siteHeader as c, type HeaderContent } from './site-header.classes';

export type SiteHeaderProps = HeaderContent & { className?: string };

/** The site header: brand, links and actions; below 1024px they fold into a Menu panel. */
export function SiteHeader({ brand, links = [], actions = [], variant = 'inline', currentPath, sticky = true, id = 'site-header', className }: SiteHeaderProps) {
  const centered = variant === 'centered';
  return (
    <header data-header="" data-variant={variant} className={cn(c.root, sticky && c.sticky, className)}>
      <a href="#main" className={c.skip}>Skip to content</a>
      <div className={c.bar}>
        <div className={cn('flex', centered && c.brandCentered)}><a href={brand.href} className={c.brand}>{brand.label}</a></div>
        <button type="button" aria-expanded="false" aria-controls={`${id}-menu`} data-header-toggle="" className={c.toggle}>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" className="group-aria-expanded:hidden"><path d={MENU_PATH} /></svg>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" className="hidden group-aria-expanded:block"><path d={CLOSE_PATH} /></svg>
          Menu
        </button>
        <div id={`${id}-menu`} data-header-menu="" className={c.menu}>
          {links.length > 0 && (
            <nav aria-label="Main" className={cn(c.nav, centered && c.navCentered)}>
              <ul className={c.list}>
                {links.map((l) => (
                  <li key={l.href}>
                    <a href={l.href} aria-current={l.href === currentPath ? 'page' : undefined} className={c.link}>{l.label}</a>
                  </li>
                ))}
              </ul>
            </nav>
          )}
          {actions.length > 0 && (
            <div className={cn(c.actions, centered && c.actionsCentered)}>
              {actions.map((a) => <Button key={a.href} href={a.href} variant={a.primary ? 'default' : 'ghost'}>{a.label}</Button>)}
            </div>
          )}
        </div>
      </div>
    </header>
  );
}