Header

Site header with logo, action button, responsive desktop navigation, and built-in animated mobile menu.

Installation

pnpm add @wandercom/design-system-web

Usage

The Header block provides a fixed navigation bar at the top of your site with a logo, action button, responsive desktop navigation, and built-in mobile menu. When its container is at least 65rem (1040px) wide, menu items are rendered as horizontal navigation with dropdowns for sections. Below that width, opening the menu expands the header height with a 480ms cubic-bezier(0.4, 0, 0.6, 1) reveal while the hamburger icon morphs into an X.

Render the header conditionally based on auth state: pass user and avatarHref when signed in, and use different menu item arrays for signed-in vs signed-out (e.g. "Sign in or sign up" vs "View profile", "Sign out").

70 lines
"use client";

import { Button, Header, Logo } from '@wandercom/design-system-web';
import { Avatar } from '@wandercom/design-system-web/ui/avatar';
import type { MenuItem } from '@wandercom/design-system-web/blocks/header';
import { useTheme } from 'next-themes';

const getSignedOutMenuItems = (handleThemeChange: () => void): MenuItem[] => [
  { type: 'link', label: 'Sign in or sign up', href: '/auth/signin', hideOnMobile: true },
  { type: 'separator' },
  { type: 'link', label: 'Download mobile app', href: '/app' },
  { type: 'link', label: 'List on Wander', href: '/list' },
  { type: 'link', label: 'Visit help center', href: '/help' },
  { type: 'separator' },
  { type: 'action', label: 'Change theme', onClick: handleThemeChange },
];

const getSignedInMenuItems = (handleThemeChange: () => void): MenuItem[] => [
  { type: 'link', label: 'View profile', href: '/profile', hideOnMobile: true },
  { type: 'link', label: 'View trips', href: '/trips' },
  { type: 'link', label: 'View wishlist', href: '/wishlist' },
  { type: 'link', label: 'Chat with concierge', href: '/concierge' },
  { type: 'link', label: 'Try AI search', href: '/search' },
  { type: 'separator' },
  { type: 'link', label: 'Download mobile app', href: '/app' },
  { type: 'link', label: 'List on Wander', href: '/list' },
  { type: 'link', label: 'Visit help center', href: '/help' },
  { type: 'separator' },
  { type: 'action', label: 'Change theme', onClick: handleThemeChange },
  { type: 'separator' },
  { type: 'link', label: 'Sign out', href: '/auth/signout', hideOnMobile: true },
];

export function AppHeader() {
  const { resolvedTheme, setTheme } = useTheme();
  const signedIn = true; // from your auth provider
  const handleThemeChange = () => {
    setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
  };

  return (
    <Header
      actionButton={<Button size="md" variant="secondary">List on Wander</Button>}
      avatarHref={signedIn ? '/profile' : undefined}
      logo={<Logo className="h-6 w-auto text-primary" />}
      logoIcon={<Logo className="h-[22px] w-auto text-primary" variant="logomark" />}
      menuCtaButton={
        signedIn ? (
          <div className="flex flex-col gap-3">
            <Button asChild size="lg" variant="outline">
              <a href="/profile" className="flex items-center gap-2">
                <Avatar alt="User name" fullName="User name" size="sm" src="/avatar.jpg" />
                User name
              </a>
            </Button>
            <Button asChild size="lg" variant="primary">
              <a href="/auth/signout">Sign out</a>
            </Button>
          </div>
        ) : (
          <Button asChild size="lg" variant="primary">
            <a href="/auth/signin">Sign in or sign up</a>
          </Button>
        )
      }
      menuItems={signedIn ? getSignedInMenuItems(handleThemeChange) : getSignedOutMenuItems(handleThemeChange)}
      user={signedIn ? { avatarSrc: '/avatar.jpg', avatarAlt: 'User name', fullName: 'User name' } : undefined}
    />
  );
}

Example

The Header is best viewed in fullscreen. Open in a new tab and use the controls in the bottom-left to switch between signed-in and signed-out states and theme.

Loading example...

Desktop and mobile navigation

At container widths of 65rem (1040px) and wider, menu items render inline as horizontal navigation. Sections become Dropdown menus with hover and click interaction; direct links render as ghost button links.

On mobile, opening the menu expands the header to fill the viewport with a 480ms cubic-bezier(0.4, 0, 0.6, 1) height transition; items are revealed by overflow-hidden clipping as the header grows, with no per-item stagger. The hamburger icon morphs into an X. Sections become expandable accordions, and links display as list items.

Accessibility and behavior: the menu locks body scroll while open, exposes ARIA labels on buttons, and respects prefers-reduced-motion.

Mobile action button

Use mobileActionButton to render a different CTA in narrow header containers. This is useful when the desktop CTA is too wide or when mobile users need a different primary action. Keep sign-in actions inside the menu.

6 lines
<Header
  logo={<Logo className="h-6 w-auto text-primary" />}
  actionButton={<Button variant="secondary" size="md">List on Wander</Button>}
  mobileActionButton={<Button variant="primary" size="md">Explore stays</Button>}
  menuItems={menuItems}
/>

When mobileActionButton is not provided, the header falls back to actionButton at all container widths. Pass null to omit the CTA in the narrow layout. The user avatar is hidden in the narrow layout regardless.

Center content

Use centerContent to place content such as a SearchBar in the center of the header row. The content is absolutely centered, so constrain its width for narrow and wide header containers. Pass inheritContainer to SearchBar so it follows the Header query container.

9 lines
<Header
  centerContent={
    <div className="w-[calc(100cqw-7.5rem)] @min-[65rem]:w-[calc(100cqw-29rem)]">
      <SearchBar inheritContainer />
    </div>
  }
  logo={<Logo className="h-6 w-auto text-primary" />}
  mobileActionButton={null}
/>

Scroll Effect

Enable scrollEffect to add a background and bottom border when the user scrolls past the top of the page. The transition uses ease-out-cubic over 200ms.

6 lines
<Header
  logo={<Logo className="h-6 w-auto text-primary" />}
  actionButton={<Button variant="secondary" size="md">List on Wander</Button>}
  scrollEffect
  menuItems={menuItems}
/>

Inverting on Transparent Backgrounds

Pages with dark heroes above the fold need header content that reads against the dark image. Enable invertOnTransparent to force the header into dark mode while the background is transparent; once scrollEffect swaps to a solid background, the header follows the page theme again.

7 lines
<Header
  logo={<Logo className="h-6 w-auto text-primary" />}
  actionButton={<Button variant="secondary" size="md">List on Wander</Button>}
  scrollEffect
  invertOnTransparent
  menuItems={menuItems}
/>

Only light-mode pages visibly invert — in dark mode the header content is already light-on-dark, so the prop is a no-op.

Pair with per-route logic on the consumer to apply invertOnTransparent only on pages that actually have a dark hero. Route groups or a thin AppHeader wrapper are both good fits.

Live example

Open in fullscreen, scroll past the hero, and toggle invertOnTransparent to see the failure case (header content vanishes against the dark image).

Loading example...

Advanced: Dark Section Theme Sync

For headers that need to flip dark while overlapping dark sections anywhere on the page (not just transparent heroes at the top), use the useHeaderThemeSync hook directly in a custom header. The built-in Header block does not expose this as a prop — invertOnTransparent covers the common hero case.

12 lines
import { useHeaderThemeSync } from '@wandercom/design-system-shared/hooks/use-header-theme-sync';

function CustomHeader() {
  const headerRef = useRef<HTMLElement>(null);
  const theme = useHeaderThemeSync(headerRef);

  return (
    <header ref={headerRef} data-theme={theme}>
      {/* ... */}
    </header>
  );
}

An IntersectionObserver keyed to the header's height detects overlapping dark sections, and a MutationObserver picks up sections added or re-themed after mount. Returns "dark" when the header overlaps a matching element, "light" when it does not, or undefined before the first check or when disabled.

An options object is accepted as the second argument:

4 lines
const theme = useHeaderThemeSync(headerRef, {
  enabled: true,
  selector: '[data-theme="dark"]:not([data-theme-scope="local"])',
});
  • enabled (default true) — set to false to disable observation. Returns undefined when disabled.
  • selector (default [data-theme="dark"]:not([data-theme-scope="local"])) — CSS selector for elements that trigger the dark theme.

Scoping with data-theme-scope

For locally-themed components (e.g. a dark card or tooltip inside a light page) that should not trigger the sync, add data-theme-scope="local" alongside data-theme="dark":

9 lines
{/* This WILL trigger the sync */}
<section data-theme="dark" className="bg-black text-white py-24">
  <h2>Full-width dark hero</h2>
</section>

{/* This will NOT trigger the sync */}
<div data-theme="dark" data-theme-scope="local" className="rounded-lg bg-black p-6">
  <p>Dark card that doesn't affect the header</p>
</div>

Props

logo?:

React.ReactNode
Logo content displayed on the left side. Can be the Logo component, an image, SVG, or any React node.

logoIcon?:

React.ReactNode
Compact logo icon displayed in the narrow header layout. Falls back to logo if not provided.

actionButton?:

React.ReactNode
CTA button displayed on the right side of the header. Typically a Button component prompting user action. In the narrow layout, falls back to this when mobileActionButton is not provided.

mobileActionButton?:

React.ReactNode
Narrow-layout CTA button. When omitted, falls back to actionButton below the header container breakpoint. Pass null to omit the narrow-layout CTA.

centerContent?:

React.ReactNode
Optional content centered in the header row, such as a SearchBar.

user?:

HeaderUser
User information for signed-in state. When provided, displays an avatar (40px) between the action button and menu button.

avatarHref?:

string
URL for the avatar link. When provided, the avatar becomes clickable. Typically links to the user profile or account settings.

onMenuOpenChange?:

(open: boolean) => void
Callback fired when the mobile menu open state changes. Enables controlled mode when provided alongside menuOpen.

layout?:

"default" | "wide"
Layout style for the Container wrapping the header content. Defaults to "default".

scrollEffect?:

boolean
Enables background and border transition on scroll. When true, the header gains a solid background and bottom border after the user scrolls past the top of the page. Default false.

invertOnTransparent?:

boolean
Forces dark-themed header content while the header is transparent (before scrollEffect kicks in on scroll). Intended for pages with a dark hero above the fold so light-mode header content remains legible. No-op in dark mode. Default false.

className?:

string
Additional CSS classes to apply to the header element.

containerClassName?:

string
Additional CSS classes to apply to the Container wrapping the header content.

HeaderUser

avatarSrc?:

string
User avatar image URL.

avatarAlt:

string
Alt text for the avatar image. Required for accessibility.

fullName?:

string
User full name for fallback display when no avatar image is provided.

MenuItem is a discriminated union of MenuSection, MenuLink, MenuAction, and MenuSeparator. MenuSection, MenuLink, and MenuAction each support an optional hideOnMobile?: boolean field that omits the item from the mobile menu while keeping it visible on desktop.

MenuSection (expandable section with sub-items):

type:

"section"
Identifies this item as an expandable section.

label:

string
Display text for the section header.

items:

Array<{ label: string; href: string }>
Array of link items within the section. Each item has a label and href.

hideOnMobile?:

boolean
When true, hides this item in the narrow header layout. Useful for items that have a different narrow-layout equivalent (e.g., a sign-in CTA that is promoted to the bottom of the menu via menuCtaButton).

MenuLink (direct navigation link):

type:

"link"
Identifies this item as a simple navigation link.

label:

string
Display text for the link.

href:

string
URL the link navigates to.

hideOnMobile?:

boolean
When true, hides this item in the narrow header layout. Useful for items that have a different narrow-layout equivalent (e.g., a sign-in CTA that is promoted to the bottom of the menu via menuCtaButton).

MenuAction (direct action):

type:

"action"
Identifies this item as a simple action.

label:

string
Display text for the action.

onClick:

() => void
Action to run when selected.

hideOnMobile?:

boolean
When true, hides this item in the narrow header layout. Useful for items that have a different narrow-layout equivalent (e.g., a sign-in CTA that is promoted to the bottom of the menu via menuCtaButton).

MenuSeparator (visual divider between groups):

type:

"separator"
Identifies this item as a divider. Renders as a horizontal rule on desktop. Separators are always hidden on mobile — the mobile menu does not render separators.

Mobile

pnpm add @wandercom/design-system-mobile

Mobile Usage

The mobile header uses the logomark (icon) variant of the logo for a more compact display. It includes a built-in menu button.

11 lines
import { Header } from '@wandercom/design-system-mobile/blocks/header';
import { Logo } from '@wandercom/design-system-mobile/ui/logo';

export function Example() {
  return (
    <Header
      logo={<Logo variant="logomark" height={22} width={21} color="#000" />}
      onMenuPress={() => setMenuOpen(true)}
    />
  );
}

Mobile Props

logo?:

React.ReactNode
Logo content displayed on the left side. Should use the logomark variant for mobile.

onMenuPress?:

() => void
Callback fired when the menu button is pressed. Use this to open your menu or navigation drawer.

className?:

string
Additional NativeWind classes to apply to the header.
Header