Skip to content
    Style: Wireframe

    Blocks · Navigation

    Site header

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

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

    Used in UI Pro: Header

    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/ui/r/site-header.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/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 themes.dev UI 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>
      );
    }