Skip to content
    Style: Wireframe

    Blocks · Navigation

    Header bar

    Brand, a row of links and one primary button; under 672px of its own width the links fold into a Menu disclosure (Escape closes it).

    71% of the 2,073 sites in The UI Index have a header (1,473 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/ui/r/header-bar.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/header-bar.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.

    header-bar.classes.ts
    /** The header bar: the brand, a row of links and one primary button. Wide enough (672px of the block's own width,
     *  a container query) they sit in one row; narrower, the links and the button fold into a panel under the bar behind
     *  a Menu button (a disclosure: Escape closes it, following a link closes it). Without JavaScript the panel simply
     *  shows under the bar, so every link is reachable. */
    export const headerBar = {
      root: 'group/header @container/header relative z-40 border-b bg-background text-foreground',
      bar: 'mx-auto flex max-w-6xl flex-wrap items-center gap-x-4 px-6 py-3 @2xl/header:flex-nowrap',
      brand: '-mx-2 flex h-10 shrink-0 items-center gap-2 rounded-md px-2 text-[15px] font-semibold tracking-[-0.02em] transition-opacity hover:opacity-75 active:opacity-60',
      // Only shown once the script has marked the header (data-js), and only below the fold.
      toggle:
        'group ml-auto hidden 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 group-data-[js]/header:@max-2xl/header:inline-flex [&_svg]:size-4',
      toggleOpen: 'group-aria-expanded:hidden',
      toggleClose: 'hidden group-aria-expanded:block',
      menu: 'flex basis-full flex-col gap-4 pb-3 @max-2xl/header:data-collapsed:hidden @2xl/header:basis-auto @2xl/header:flex-1 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-6 @2xl/header:pb-0',
      nav: '@2xl/header:ml-2',
      list: 'm-0 flex list-none flex-col p-0 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-1',
      link: 'flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed',
      action: 'flex flex-col gap-2 @2xl/header:ml-auto @2xl/header:flex-row @2xl/header:items-center',
    };
    
    export type HeaderBarLink = { label: string; href: string };
    export type HeaderBarContent = {
      brand: { label: string; href: string };
      links?: HeaderBarLink[];
      /** The one primary button. */
      action?: { label: string; href: string };
      /** The link whose href equals this is the current page. */
      currentPath?: string;
      /** The nav's name for screen readers. Default "Main". */
      label?: string;
      /** The Menu button's text below the fold. Default "Menu". */
      menuLabel?: string;
      /** Unique on the page; ids the menu. */
      id?: string;
    };
    header-bar.client.ts
    // Header bar behaviour, shared by the Astro, React and HTML versions: the Menu button below the fold. The server HTML
    // shows the menu under the bar, so without JavaScript every link is there; enhanceHeaderBar() marks the header with
    // data-js (which shows the Menu button) and collapses the menu (data-collapsed). A click (or Enter/Space) opens and
    // closes it; Escape closes it and, when focus is inside the header, returns focus to the button; following a link in it closes it.
    declare global {
      interface Window {
        __caseroomHeaderBar?: boolean;
      }
    }
    
    const ROOT = '[data-header-bar]';
    
    const setMenu = (root: Element, open: boolean, focus = false) => {
      const toggle = root.querySelector<HTMLElement>('[data-header-bar-toggle]');
      toggle?.setAttribute('aria-expanded', String(open));
      root.querySelectorAll('[data-header-bar-menu]').forEach((m) => m.toggleAttribute('data-collapsed', !open));
      if (focus) toggle?.focus();
    };
    
    export function enhanceHeaderBar(root: Element) {
      if (root.hasAttribute('data-js')) return;
      root.setAttribute('data-js', '');
      root.querySelectorAll('[data-header-bar-menu]').forEach((m) => m.setAttribute('data-collapsed', ''));
    }
    
    if (typeof window !== 'undefined' && !window.__caseroomHeaderBar) {
      window.__caseroomHeaderBar = true;
      document.querySelectorAll(ROOT).forEach(enhanceHeaderBar);
      document.addEventListener('click', (e) => {
        const target = e.target as Element;
        const root = target.closest(ROOT);
        if (!root?.hasAttribute('data-js')) return;
        const toggle = target.closest('[data-header-bar-toggle]');
        if (toggle) return setMenu(root, toggle.getAttribute('aria-expanded') !== 'true');
        if (target.closest('[data-header-bar-menu] a')) setMenu(root, false);
      });
      document.addEventListener('keydown', (e) => {
        if (e.key !== 'Escape') return;
        document.querySelectorAll(`${ROOT}[data-js]`).forEach((root) => {
          if (root.querySelector('[data-header-bar-toggle][aria-expanded="true"]')) setMenu(root, false, root.contains(document.activeElement));
        });
      });
    }
    
    export {};
    header-bar.tsx
    import { useEffect, useRef } from 'react';
    import { Button } from '@/components/ui/button';
    import { Icon } from '@/components/ui/icon';
    import { cn } from '@/lib/utils';
    import { enhanceHeaderBar } from './header-bar.client';
    import { headerBar as c, type HeaderBarContent } from './header-bar.classes';
    
    export type HeaderBarProps = HeaderBarContent & { className?: string };
    
    /** The site's top bar: brand, links and one primary button; below 672px of its own width the links fold behind a Menu button. */
    export function HeaderBar({ brand, links = [], action, currentPath, label = 'Main', menuLabel = 'Menu', id = 'header-bar', className }: HeaderBarProps) {
      const ref = useRef<HTMLElement>(null);
      useEffect(() => {
        if (ref.current) enhanceHeaderBar(ref.current);
      }, []);
      return (
        <header ref={ref} data-header-bar="" className={cn(c.root, className)}>
          <div className={c.bar}>
            <a href={brand.href} className={c.brand}>{brand.label}</a>
            <button type="button" aria-expanded="false" aria-controls={`${id}-menu`} data-header-bar-toggle="" className={c.toggle}>
              <Icon name="menu" className={c.toggleOpen} />
              <Icon name="x" className={c.toggleClose} />
              {menuLabel}
            </button>
            <div id={`${id}-menu`} data-header-bar-menu="" className={c.menu}>
              {links.length > 0 && (
                <nav aria-label={label} className={c.nav}>
                  <ul role="list" 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>
              )}
              {action && <div className={c.action}><Button href={action.href}>{action.label}</Button></div>}
            </div>
          </div>
        </header>
      );
    }