Docs

This page isn't translated yet

Header

Add a site header with navigation, dropdowns, a call to action and an accessible mobile menu from one HeaderInstance.

On this page

HeaderInstance renders a complete site header from data: a logo link, a navigation bar with dropdowns, a call-to-action slot, and the off-canvas mobile menu with its toggle, backdrop and keyboard handling. You choose at which breakpoint the navigation collapses into the menu. The parts it is built from are exported too, for headers that need a different structure.

import { Button, Heading, HeaderInstance, Paragraph, Section } from '@systhemaui/next'

const logo = (
  <svg viewBox="0 0 150 40" fill="none" aria-hidden="true">
    <rect y="4" width="32" height="32" rx="9" fill="currentColor" />
    <path
      d="M9 28V12l14 16V12"
      stroke="var(--color-layout-bg-main)"
      strokeWidth="3.5"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
    <text x="42" y="28" fontSize="19" fontWeight="700" fill="currentColor">
      Northwind
    </text>
  </svg>
)

export default function Demo() {
  return (
    <>
      <HeaderInstance
        logo={logo}
        logoHref="#"
        navigationItems={[
          { type: 'link', label: 'Work', url: '#', isActive: true },
          {
            type: 'subNavigation',
            label: 'Services',
            subNavigationItems: [
              { label: 'Brand identity', url: '#' },
              { label: 'Web design', url: '#' },
              { label: 'Development', url: '#' },
            ],
          },
          { type: 'link', label: 'Journal', url: '#' },
        ]}
        cta={
          <Button.a href="#" variant="primary">
            Contact us
          </Button.a>
        }
        elementProps={{
          headerLogo: { 'aria-label': 'Northwind home' },
          headerNavigationItem: { className: 'max-md:hidden' },
          headerMenuToggle: { className: 'md:hidden' },
          headerCta: { className: 'max-md:hidden' },
          menu: { className: 'md:hidden' },
          menuBackdrop: { className: 'md:hidden' },
        }}
      />
      <Section padding={2}>
        <Heading.h2>Page content</Heading.h2>
        <Paragraph>
          Hover Services for the dropdown. Switch the preview to the phone viewport and open the
          menu.
        </Paragraph>
      </Section>
    </>
  )
}

ImportLink to this section

import { HeaderInstance } from '@systhemaui/next'

In a React app without Next.js, import it from @systhemaui/react.

Where to render itLink to this section

Render HeaderInstance as a direct child of <body> (inside SysthemaProvider, which adds no element), before <main>. It renders four siblings: the <header>, the <menu id="header-menu"> overlay, the backdrop and a client listener. While the menu is open, the listener makes every other child of <body> inert, so a wrapper element around the header would make the menu itself inert. Use one HeaderInstance per page, since the menu has a fixed id.

app/layout.tsx
import { HeaderInstance, SysthemaProvider } from '@systhemaui/next'
import type { ReactNode } from 'react'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SysthemaProvider>
          <HeaderInstance position="sticky" navigationItems={[]} />
          <main id="main-content">{children}</main>
        </SysthemaProvider>
      </body>
    </html>
  )
}

Payload projects get this from RootHeader, which reads the navigation from the Header global. See Header and footer globals.

navigationItems is an array of two kinds of item:

  • { type: 'link', label, url, isActive?, openInNewTab? }: a link.
  • { type: 'subNavigation', label, subNavigationItems, isActive? }: a dropdown trigger. Each sub-item is { label, url, isActive?, openInNewTab? }.

In the bar, a dropdown opens on hover and on keyboard focus. In the mobile menu, the trigger is a button that expands its sub-items in place. isActive adds the is-active class and aria-current="page".

ExamplesLink to this section

Collapsing at a breakpointLink to this section

HeaderInstance hides nothing on its own: at every width it renders both the navigation items and the menu toggle. Hide one or the other per breakpoint with classes in elementProps, as in the preview above (md there, so the preview shows the bar at page width). The Payload template collapses at lg:

import { HeaderInstance } from '@systhemaui/next'

export function SiteHeader() {
  return (
    <HeaderInstance
      navigationItems={[{ type: 'link', label: 'Work', url: '/work' }]}
      elementProps={{
        headerNavigationItem: { className: 'max-lg:hidden' },
        headerMenuToggle: { className: 'lg:hidden' },
        headerCta: { className: 'max-lg:hidden' },
        menu: { className: 'lg:hidden' },
        menuBackdrop: { className: 'lg:hidden' },
      }}
    />
  )
}

Hide the bar's items and the CTA at every width to keep only the toggle. The menu repeats the navigation and the CTA, so nothing is lost.

import { Button, Heading, HeaderInstance, Paragraph, Section } from '@systhemaui/next'

const logo = (
  <svg viewBox="0 0 40 40" fill="none" aria-hidden="true">
    <rect y="4" width="32" height="32" rx="9" fill="currentColor" />
    <path
      d="M9 28V12l14 16V12"
      stroke="var(--color-layout-bg-main)"
      strokeWidth="3.5"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
  </svg>
)

export default function Demo() {
  return (
    <>
      <HeaderInstance
        logo={logo}
        logoHref="#"
        navigationItems={[
          { type: 'link', label: 'Work', url: '#' },
          {
            type: 'subNavigation',
            label: 'Services',
            subNavigationItems: [
              { label: 'Brand identity', url: '#' },
              { label: 'Web design', url: '#' },
            ],
          },
          { type: 'link', label: 'Journal', url: '#' },
          { type: 'link', label: 'About', url: '#' },
        ]}
        cta={
          <Button.a href="#" variant="primary">
            Contact us
          </Button.a>
        }
        elementProps={{
          headerLogo: { 'aria-label': 'Northwind home' },
          headerNavigationItem: { className: 'hidden' },
          headerCta: { className: 'hidden' },
        }}
      />
      <Section padding={2}>
        <Heading.h2>Open the menu</Heading.h2>
        <Paragraph>
          The toggle opens the menu from the right. Escape, the close button or the backdrop
          closes it.
        </Paragraph>
      </Section>
    </>
  )
}

ThemeLink to this section

theme sets data-theme on the header and, unless elementProps.menu.theme says otherwise, on the menu. Header colors come from the header.* color tokens of that mode.

import { Button, HeaderInstance } from '@systhemaui/next'

const logo = (
  <svg viewBox="0 0 150 40" fill="none" aria-hidden="true">
    <rect y="4" width="32" height="32" rx="9" fill="currentColor" />
    <text x="42" y="28" fontSize="19" fontWeight="700" fill="currentColor">
      Northwind
    </text>
  </svg>
)

export default function Demo() {
  return (
    <HeaderInstance
      theme="dark"
      logo={logo}
      logoHref="#"
      navigationItems={[
        { type: 'link', label: 'Pricing', url: '#' },
        { type: 'link', label: 'Docs', url: '#' },
      ]}
      cta={
        <Button.a href="#" variant="secondary">
          Sign in
        </Button.a>
      }
      elementProps={{
        headerLogo: { 'aria-label': 'Northwind home' },
        headerMenuToggle: { className: 'md:hidden' },
        menu: { className: 'md:hidden' },
        menuBackdrop: { className: 'md:hidden' },
      }}
    />
  )
}

PositionLink to this section

position is static (the default), sticky or fixed, and adds header-static, header-sticky or header-fixed. With a sticky or fixed header the menu is position: fixed too, so it covers the viewport wherever the page is scrolled. A preview frame grows with its content and never scrolls, so try sticky in your own layout. The scroll-on-top and is-scrolling-down body classes from ScrollClassesListener let you hide or restyle a sticky header while scrolling.

Customizing slots with elementPropsLink to this section

elementProps passes props to each part by slot name: headerContainer, headerLogo, headerNavigation, headerNavigationItem, headerMenuToggle, headerSubNavigation, headerSubNavigationItem, headerCta, menu, menuBackdrop, menuHeader, menuToggle, menuContainer, menuNavigation, menuNavigationItem, menuSubNavigation, menuSubNavigationItem and menuCta.

A className you pass is appended to the classes the part needs: headerContainer: { className: 'max-w-none' } renders header-container container max-w-none, and a menuToggle class keeps the closed state class. To replace Systhema's styling, override the class in your own @layer app rules instead.

Composing from partsLink to this section

The parts are exported for headers that HeaderInstance can't express. A hand-composed header has no mobile-menu behaviour (the listener is internal to HeaderInstance), so use the parts for headers without a menu, or keep HeaderInstance and adjust it through elementProps.

Composed header
import {
  Button,
  Header,
  HeaderCta,
  HeaderNavigation,
  HeaderNavigationItem,
  HeaderSubNavigation,
  HeaderSubNavigationItem,
} from '@systhemaui/next'

export default function Demo() {
  return (
    <Header>
      <a className="header-logo text-h5" href="#">
        Northwind
      </a>
      <HeaderNavigation>
        <HeaderNavigationItem url="#" isActive>
          Work
        </HeaderNavigationItem>
        <HeaderNavigationItem hasSubNavigation>
          Services
          <HeaderSubNavigation>
            <HeaderSubNavigationItem url="#">Brand identity</HeaderSubNavigationItem>
            <HeaderSubNavigationItem url="#">Web design</HeaderSubNavigationItem>
          </HeaderSubNavigation>
        </HeaderNavigationItem>
      </HeaderNavigation>
      <HeaderCta>
        <Button.a href="#" variant="primary">
          Contact
        </Button.a>
      </HeaderCta>
    </Header>
  )
}

The other parts are HeaderMenuToggle, Menu, MenuBackdrop, MenuHeader, MenuContainer, MenuNavigation, MenuNavigationItem, MenuSubNavigation, MenuSubNavigationItem and MenuCta.

logoHref defaults to /. On a multi-locale site, pass the locale's own home path. See Locale-aware logo links.

PropsLink to this section

HeaderInstanceLink to this section

labels sets the accessible names: menu for both toggles (default 'Menu'), mainNavigation ('Main navigation'), mobileNavigation ('Mobile navigation') and navigationMenu ('Navigation menu'). HeaderMenuToggle also takes a label prop of its own. Every other prop goes to the <header> element, which gets id="header" unless you pass an id.

PropTypeDefaultDescription
themeColorSystem (dark, default)-
position'static' | 'sticky' | 'fixed'-
containerProps(ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { className?: string | undefined; })-
logoElement-
logoHrefstring-Where the logo links to. Defaults to /. On a multi-locale site the site root is locale-specific (/hu, or a locale domain's own /), so pass the locale-correct home path here — @systhemaui/payload's RootHeader does that automatically.
navigationItemsNavigationItemProps[]-
ctaElement-
labels{ menu?, mainNavigation?, mobileNavigation?, navigationMenu? }-
elementProps{ headerContainer?, headerLogo?, headerNavigation?, headerNavigationItem?, headerMenuToggle?, headerSubNavigation?, headerSubNavigationItem?, headerCta?, menu?, menuBackdrop?, menuHeader?, menuToggle?, menuContainer?, menuNavigation?, menuNavigationItem?, menuSubNavigation?, menuSubNavigationItem?, menuCta? }{}
…and all <div> attributes
PropTypeDefaultDescription
themeColorSystem (dark, default)-
position'static' | 'sticky' | 'fixed'-
containerProps(ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { className?: string | undefined; })-
…and all <header> attributes
PropTypeDefaultDescription
themeColorSystem (dark, default)-
…and all <menu> attributes

HeaderNavigationItemProps is the union described in Navigation items: a link item also has url and openInNewTab, a subNavigation item has subNavigationItems. MenuNavigationItemProps and MenuSubNavigationItemProps are aliases of the two types below.

PropTypeDefaultDescription
type (required)'link' | 'subNavigation'-
label (required)string-
isActiveboolean-
PropTypeDefaultDescription
label (required)string-
url (required)string-
isActiveboolean-
openInNewTabboolean-

HTML and CSSLink to this section

HeaderInstance renders this structure; the HTML template wires the menu with the vanilla listeners (see Vanilla JavaScript):

<header class="header header-sticky" id="header">
  <div class="header-container container">
    <a class="header-logo" href="/" aria-label="Northwind home">…</a>
    <nav class="header-navigation" aria-label="Main navigation">
      <a class="header-navigation-item is-active max-lg:hidden" href="/work" aria-current="page"
        >Work</a
      >
      <div class="header-navigation-item has-subnav max-lg:hidden">
        Services
        <div class="header-sub-navigation">
          <div class="header-sub-navigation-wrapper">
            <a class="header-navigation-item" href="/services/web">Web design</a>
          </div>
        </div>
        <span class="header-navigation-item-icon header-navigation-item-icon-subnavigation"></span>
      </div>
      <button
        type="button"
        class="header-navigation-menu-toggle lg:hidden"
        aria-controls="header-menu"
        aria-expanded="false"
        aria-haspopup="dialog"
      >
        <span class="header-navigation-menu-toggle-label sr-only!">Menu</span>
        <span class="header-navigation-menu-toggle-icon"></span>
      </button>
    </nav>
    <div class="header-cta max-lg:hidden">…</div>
  </div>
</header>
<menu id="header-menu" class="menu lg:hidden" aria-label="Navigation menu">
  <div class="menu-header">
    <button type="button" class="menu-toggle closed" aria-controls="header-menu">…</button>
  </div>
  <div class="menu-container">
    <nav class="menu-navigation" aria-label="Mobile navigation">…</nav>
    <div class="menu-cta">…</div>
  </div>
</menu>
<div class="menu-backdrop lg:hidden"></div>

The dropdown, open and close icons and an animated three-line menu icon are configured with blocks.header (see Component CSS blocks). Sizes, colors and shadows come from the header.* tokens:

ClassStyles
header--tw-shadow: var(--tw-shadow-color);--tw-shadow-color: var(--color-header-shadow, rgba(0, 0, 0, 0.10));user-select: none;isolation: isolate;position: static;top: 0;left: 0;z-index: 40;display: flex;align-items: center;box-shadow: var(--header-shadow-x) var(--header-shadow-y) var(--header-shadow-blur) var(--header-shadow-spread) var(--tw-shadow-color, rgba(0, 0, 0, 0.10));height: var(--header-height, 64px);background-color: var(--color-header-background, transparent);
header-staticposition: static;
header-fixedposition: fixed;
header-stickyposition: sticky;
header-containerdisplay: flex;align-items: center;padding-inline: var(--header-container-padding-x, 0px);column-gap: var(--header-container-gap-x, 24px);
header-navigationdisplay: flex;align-items: center;justify-content: flex-end;column-gap: var(--header-navigation-gap-x, 12px);flex-grow: 1;
header-ctadisplay: flex;align-items: center;justify-content: flex-end;column-gap: var(--header-navigation-gap-x, 12px);
header-navigation-item& { --tw-shadow: var(--tw-shadow-color); --tw-shadow-color: var(--color-header-navigation-item-normal-shadow, transparent); position: relative; display: inline-flex; align-items: center; justify-content: space-between; box-shadow: var(--header-navigation-item-shadow-x) var(--header-navigation-item-shadow-y) var(--header-navigation-item-shadow-blur) var(--header-navigation-item-shadow-spread) var(--tw-shadow-color, rgba(0, 0, 0, 0.10)); font-size: var(--header-navigation-item…+ 9 more rules
menu-navigation-item& { --tw-shadow: var(--tw-shadow-color); --tw-shadow-color: var(--color-header-navigation-item-normal-shadow, transparent); position: relative; display: inline-flex; align-items: center; justify-content: space-between; box-shadow: var(--header-navigation-item-shadow-x) var(--header-navigation-item-shadow-y) var(--header-navigation-item-shadow-blur) var(--header-navigation-item-shadow-spread) var(--tw-shadow-color, rgba(0, 0, 0, 0.10)); font-size: var(--header-navigation-item…+ 11 more rules
header-navigation-item-icon& { margin-right: calc(var(--header-navigation-gap-x) * -1); }.header-navigation-item:has(.header-navigation-item-icon) { margin-right: calc(var(--header-navigation-item-padding-x) * -0.5); }
header-navigation-menu-toggle& { --tw-shadow: var(--tw-shadow-color); --tw-shadow-color: var(--color-header-navigation-menu-toggle-normal-shadow, transparent); position: relative; display: inline-flex; align-items: center; justify-content: space-between; box-shadow: var(--header-navigation-menu-toggle-shadow-x) var(--header-navigation-menu-toggle-shadow-y) var(--header-navigation-menu-toggle-shadow-blur) var(--header-navigation-menu-toggle-shadow-spread) var(--tw-shadow-color, rgba(0, 0, 0, 0.10)); font…+ 22 more rules
menu-toggle& { --tw-shadow: var(--tw-shadow-color); --tw-shadow-color: var(--color-header-navigation-menu-toggle-normal-shadow, transparent); position: relative; display: inline-flex; align-items: center; justify-content: space-between; box-shadow: var(--header-navigation-menu-toggle-shadow-x) var(--header-navigation-menu-toggle-shadow-y) var(--header-navigation-menu-toggle-shadow-blur) var(--header-navigation-menu-toggle-shadow-spread) var(--tw-shadow-color, rgba(0, 0, 0, 0.10)); font…+ 22 more rules
menu& { --tw-shadow-color: transparent; --tw-shadow: var(--tw-shadow-color); user-select: none; isolation: isolate; z-index: 50; position: absolute; top: 0; right: 0; height: 100svh; width: 100%; display: flex; flex-direction: column; will-change: transform, box-shadow; transform: translate3d(100%, 0, 0); transition-property: transform, box-shadow; transition-duration: 0.3s; transition-timing-function: ease-out; transition-delay: 0s; box-shadow: var(--header-menu-shadow-x) var(-…+ 3 more rules
is-open.menu.is-open + .menu-backdrop { pointer-events: auto; opacity: 1; }
header-sub-navigation& { pointer-events: none; opacity: 0; isolation: isolate; z-index: 10; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); padding-top: calc(var(--header-sub-navigation-margin-top) + var(--header-sub-navigation-before-height)); width: var(--header-sub-navigation-width, 280px); }.header-navigation-item:hover > .header-sub-navigation, .header-navigation-item:focus-within > .header-sub-navigation { pointer-events: auto; opacity: 1; }
header-sub-navigation-wrapper& { --tw-shadow: var(--tw-shadow-color); --tw-shadow-color: var(--color-header-sub-navigation-shadow, rgba(0, 0, 0, 0.15)); isolation: isolate; position: relative; display: flex; flex-direction: column; box-shadow: var(--header-sub-navigation-shadow-x) var(--header-sub-navigation-shadow-y) var(--header-sub-navigation-shadow-blur) var(--header-sub-navigation-shadow-spread) var(--tw-shadow-color, rgba(0, 0, 0, 0.10)); padding-inline: var(--header-sub-navigation-padding-x, 16px…+ 1 more rule
header-logo& { display: inline-block; width: auto; color: var(--color-header-logo-fill, inherit); height: var(--header-logo-height, 48px); }& > * { display: inline-block; width: auto; color: var(--color-header-logo-fill, inherit); height: var(--header-logo-height, 48px); }
menu-backdrop& { pointer-events: none; opacity: 0; position: fixed; inset: 0; z-index: 45; display: block; backdrop-filter: blur(var(--header-menu-backdrop-blur)); will-change: opacity, backdrop-filter; transform: translate3d(0, 0, 0); transition-property: opacity, backdrop-filter, transform; transition-duration: 0.3s; transition-timing-function: ease-out; transition-delay: 0s; background-color: var(--color-header-menu-backdrop-background, rgba(255, 255, 255, 0.1)); }+ 1 more rule
menu-header& { display: flex; align-items: center; justify-content: flex-end; flex-shrink: 0; flex-grow: 0; height: var(--header-height, 64px); }@media (width < 1192px) { & { padding-inline: var(--container-margin, 16px); } }
menu-container& { display: flex; flex-direction: column; flex-grow: 1; flex-shrink: 1; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; padding-top: var(--header-menu-container-padding-top, 48px); padding-bottom: var(--header-menu-container-padding-bottom, 96px); row-gap: var(--header-menu-container-gap-y, 24px); padding-inline: var(--container-margin, 16px); }&::-webkit-scrollbar { display: none; }
menu-navigationdisplay: flex;flex-direction: column;row-gap: var(--header-menu-navigation-gap-y, 12px);
menu-sub-navigation& { --menu-sub-navigation-offset: var(--header-menu-navigation-gap-y, 12px); display: grid; grid-template-rows: 0fr; will-change: grid-template-rows; transition-property: grid-template-rows; transition-duration: 0.3s; transition-timing-function: ease-out; transition-delay: 0s; margin-block: calc(var(--menu-sub-navigation-offset) * -0.5); }&.is-open { grid-template-rows: 1fr; }& > * { overflow: hidden; }
menu-sub-navigation-wrapperdisplay: flex;flex-direction: column;margin-block: calc(var(--menu-sub-navigation-offset) * 0.5);padding-left: var(--header-menu-sub-navigation-padding-left, 16px);padding-right: var(--header-menu-sub-navigation-padding-right, 0px);padding-top: var(--header-menu-sub-navigation-padding-y, 16px);padding-bottom: var(--header-menu-sub-navigation-padding-y, 16px);row-gap: var(--header-menu-sub-navigation-gap-y, 12px);+ 2 more declarations
menu-ctadisplay: flex;flex-direction: column;row-gap: var(--header-menu-navigation-gap-y, 12px);

Next.jsLink to this section

The Next header renders every link (logo, items, sub-items, in the bar and the menu) through the Next link helper, so navigation is client-side and links are prefetched. Its menu listener also closes the menu on every route change (usePathname()), without moving focus, since the page changed. The React header closes the menu when a link inside it is followed.

AccessibilityLink to this section

  • Both toggles are buttons with aria-controls="header-menu", aria-haspopup="dialog" and an aria-expanded the listener keeps in sync; their name comes from labels.menu.
  • The closed menu is inert, so it is out of the tab order and the accessibility tree. While it is open, everything else in <body> is inert, focus moves into the menu, and closing returns focus to the toggle.
  • Escape, the close button and the backdrop close the menu. Following a link inside it closes it too.
  • Dropdown triggers in the menu are buttons with aria-expanded and aria-controls pointing at their sub-list. In the bar, dropdowns open on :focus-within as well as hover, so keyboard users reach the sub-links.
  • Active items carry aria-current="page", and new-tab items get rel="noopener noreferrer".
  • The logo link has no accessible name of its own. Give it one with elementProps={{ headerLogo: { 'aria-label': 'Home' } }}, or put visually hidden text in the logo.

See Keyboard and menus.