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.
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.
Navigation itemsLink to this section
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' },
}}
/>
)
}Menu at every widthLink to this section
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.
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.
Logo linkLink to this section
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.
| Prop | Type | Default | Description |
|---|---|---|---|
theme | ColorSystem (dark, default) | - | |
position | 'static' | 'sticky' | 'fixed' | - | |
containerProps | (ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { className?: string | undefined; }) | - | |
logo | Element | - | |
logoHref | string | - | 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. |
navigationItems | NavigationItemProps[] | - | |
cta | Element | - | |
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 |
HeaderLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
theme | ColorSystem (dark, default) | - | |
position | 'static' | 'sticky' | 'fixed' | - | |
containerProps | (ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { className?: string | undefined; }) | - | |
…and all <header> attributes |
MenuLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
theme | ColorSystem (dark, default) | - | |
…and all <menu> attributes |
Navigation itemsLink to this section
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.
| Prop | Type | Default | Description |
|---|---|---|---|
type (required) | 'link' | 'subNavigation' | - | |
label (required) | string | - | |
isActive | boolean | - |
| Prop | Type | Default | Description |
|---|---|---|---|
label (required) | string | - | |
url (required) | string | - | |
isActive | boolean | - | |
openInNewTab | boolean | - |
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:
| Class | Styles |
|---|---|
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-static | position: static; |
header-fixed | position: fixed; |
header-sticky | position: sticky; |
header-container | display: flex;align-items: center;padding-inline: var(--header-container-padding-x, 0px);column-gap: var(--header-container-gap-x, 24px); |
header-navigation | display: flex;align-items: center;justify-content: flex-end;column-gap: var(--header-navigation-gap-x, 12px);flex-grow: 1; |
header-cta | display: 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-navigation | display: 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-wrapper | display: 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-cta | display: 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 anaria-expandedthe listener keeps in sync; their name comes fromlabels.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>isinert, 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-expandedandaria-controlspointing at their sub-list. In the bar, dropdowns open on:focus-withinas well as hover, so keyboard users reach the sub-links. - Active items carry
aria-current="page", and new-tab items getrel="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.