# Header

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

## Installation

```bash
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").

```tsx
"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.

```tsx
"use client";

import type { MenuItem } from "@wandercom/design-system-web/blocks/header";
import { Header } from "@wandercom/design-system-web/blocks/header";
import { Avatar } from "@wandercom/design-system-web/ui/avatar";
import { Button } from "@wandercom/design-system-web/ui/button";
import { Logo } from "@wandercom/design-system-web/ui/logo";
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@wandercom/design-system-web/ui/toggle-group";
import Link from "next/link";
import { useTheme } from "next-themes";
import { useState } from "react";

const getSignedOutMenuItems = (handleThemeChange: () => void): MenuItem[] => [
  {
    type: "link",
    label: "Sign in or sign up",
    href: "/auth/signin",
    hideOnMobile: true,
  },
  { type: "separator", hideOnMobile: true },
  { 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", hideOnMobile: true },
  {
    type: "link",
    label: "Sign out",
    href: "/auth/signout",
  },
];

/**
 * Header with signed-in / signed-out toggle
 *
 * Default header example. Use the controls in the bottom-left to switch
 * between signed-in and signed-out states. Best viewed in fullscreen.
 */
function HeaderExampleWithToggles() {
  const { resolvedTheme, setTheme } = useTheme();
  const [signedIn, setSignedIn] = useState(false);
  const [hasAvatar, setHasAvatar] = useState(true);
  const handleThemeChange = () => {
    setTheme(resolvedTheme === "dark" ? "light" : "dark");
  };

  return (
    <div className="relative min-h-screen w-full bg-surface-primary">
      <Header
        actionButton={
          <Button size="md" variant="secondary">
            Get WanderOS
          </Button>
        }
        avatarHref={signedIn ? "/profile" : undefined}
        key={`${signedIn}-${hasAvatar}`}
        logo={
          <Link href="/">
            <Logo className="h-6 w-auto text-primary" />
          </Link>
        }
        logoIcon={
          <Link href="/">
            <Logo className="h-[22px] w-auto text-primary" variant="logomark" />
          </Link>
        }
        menuCtaButton={
          signedIn ? (
            <div className="flex flex-col gap-3">
              <Button asChild size="lg" variant="outline">
                <a className="flex items-center gap-2" href="/profile">
                  <Avatar
                    alt="John Doe"
                    fullName="John Doe"
                    size="sm"
                    src={
                      hasAvatar
                        ? "https://avatars.githubusercontent.com/u/6477234?v=4"
                        : undefined
                    }
                  />
                  John Doe
                </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: hasAvatar
                  ? "https://avatars.githubusercontent.com/u/6477234?v=4"
                  : undefined,
                avatarAlt: "John Doe",
                fullName: "John Doe",
              }
            : undefined
        }
      />
      <fieldset className="fixed bottom-4 left-4 z-50 flex flex-wrap items-center gap-3 rounded-2xl border border-primary border-solid bg-surface-primary p-3 shadow-lg">
        <legend className="sr-only">Demo controls</legend>
        <div className="flex items-center gap-2">
          <span className="font-medium text-secondary text-sm">Auth</span>
          <ToggleGroup
            onValueChange={(value) => {
              setSignedIn(value === "signed-in");
            }}
            type="single"
            value={signedIn ? "signed-in" : "signed-out"}
          >
            <ToggleGroupItem size="sm" value="signed-out">
              Signed out
            </ToggleGroupItem>
            <ToggleGroupItem size="sm" value="signed-in">
              Signed in
            </ToggleGroupItem>
          </ToggleGroup>
        </div>
        {signedIn && (
          <div className="flex items-center gap-2">
            <span className="font-medium text-secondary text-sm">Avatar</span>
            <ToggleGroup
              onValueChange={(value) => {
                setHasAvatar(value === "with-avatar");
              }}
              type="single"
              value={hasAvatar ? "with-avatar" : "no-avatar"}
            >
              <ToggleGroupItem size="sm" value="with-avatar">
                With image
              </ToggleGroupItem>
              <ToggleGroupItem size="sm" value="no-avatar">
                Initials only
              </ToggleGroupItem>
            </ToggleGroup>
          </div>
        )}
      </fieldset>
    </div>
  );
}

export HeaderExampleWithToggles;

```

### 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.

```tsx
<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.

```tsx
<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.

```tsx
<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.

```tsx
<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).

```tsx
"use client";

import type { MenuItem } from "@wandercom/design-system-web/blocks/header";
import { Header } from "@wandercom/design-system-web/blocks/header";
import { Button } from "@wandercom/design-system-web/ui/button";
import { Logo } from "@wandercom/design-system-web/ui/logo";
import { SearchBar } from "@wandercom/design-system-web/ui/search-bar";
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@wandercom/design-system-web/ui/toggle-group";
import Link from "next/link";
import { useState } from "react";

const menuItems: MenuItem[] = [
  { type: "link", label: "Sign in or sign up", href: "/auth/signin" },
  { 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" },
];

/**
 * `invertOnTransparent` demo
 *
 * Scroll the page to see the header swap from dark-content-over-hero to
 * solid-light-content-over-light-body. Toggle `invertOnTransparent` off to see
 * the failure case in light mode: header content becomes invisible against the
 * dark hero image.
 */
function HeaderInvertOnTransparentExample() {
  const [invert, setInvert] = useState(true);

  return (
    <div className="relative w-full bg-surface-primary">
      <Header
        actionButton={
          <Button size="md" variant="secondary">
            Get WanderOS
          </Button>
        }
        centerContent={
          <div
            className="flex @min-[65rem]:w-[calc(100cqw-29rem)] @min-[80rem]:w-[46.5rem] w-[calc(100cqw-7.5rem)] justify-center"
            data-theme="light"
          >
            <SearchBar inheritContainer />
          </div>
        }
        invertOnTransparent={invert}
        key={String(invert)}
        logo={
          <Link href="/">
            <Logo className="h-6 w-auto text-primary" />
          </Link>
        }
        logoIcon={
          <Link href="/">
            <Logo className="h-[22px] w-auto text-primary" variant="logomark" />
          </Link>
        }
        menuCtaButton={
          <Button asChild size="lg" variant="primary">
            <a href="/auth/signin">Sign in or sign up</a>
          </Button>
        }
        menuItems={menuItems}
        mobileActionButton={null}
        scrollEffect
      />

      <section
        className="relative flex min-h-[80vh] w-full items-end overflow-hidden bg-center bg-cover px-8 pb-16"
        style={{
          backgroundImage:
            "linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.6)), url(https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1600&q=80)",
        }}
      >
        <div className="text-white">
          <p className="font-medium text-sm uppercase tracking-wide opacity-80">
            Featured
          </p>
          <h1 className="mt-2 max-w-2xl font-semibold text-4xl leading-tight">
            A dark hero image above the fold
          </h1>
          <p className="mt-3 max-w-xl text-base opacity-90">
            With{" "}
            <code className="rounded bg-black/30 px-1.5 py-0.5 text-sm">
              invertOnTransparent
            </code>{" "}
            on, the header reads against this image in light mode. Scroll down
            to see it switch to a solid background that follows the page theme.
          </p>
        </div>
      </section>

      <section className="px-8 py-24">
        <h2 className="font-semibold text-3xl text-primary">
          Light content below
        </h2>
        <p className="mt-3 max-w-2xl text-base text-secondary">
          As soon as you scroll past the hero, the header gains a solid surface
          via <code>scrollEffect</code> and the inversion stops. The header now
          uses the page&apos;s actual theme.
        </p>
        <div className="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
          {[1, 2, 3, 4, 5, 6].map((i) => (
            <div
              className="rounded-2xl border border-primary bg-surface-secondary p-6"
              key={i}
            >
              <h3 className="font-medium text-lg text-primary">Card {i}</h3>
              <p className="mt-2 text-secondary text-sm">
                Spacer content so the page is tall enough to scroll past the
                hero and trigger the scroll effect.
              </p>
            </div>
          ))}
        </div>
      </section>

      <fieldset className="fixed bottom-4 left-4 z-50 flex flex-wrap items-center gap-3 rounded-2xl border border-primary border-solid bg-surface-primary p-3 shadow-lg">
        <legend className="sr-only">Demo controls</legend>
        <div className="flex items-center gap-2">
          <span className="font-medium text-secondary text-sm">
            invertOnTransparent
          </span>
          <ToggleGroup
            onValueChange={(value) => {
              setInvert(value === "on");
            }}
            type="single"
            value={invert ? "on" : "off"}
          >
            <ToggleGroupItem size="sm" value="on">
              On
            </ToggleGroupItem>
            <ToggleGroupItem size="sm" value="off">
              Off
            </ToggleGroupItem>
          </ToggleGroup>
        </div>
      </fieldset>
    </div>
  );
}

export HeaderInvertOnTransparentExample;

```

### 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.

```tsx
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:

```tsx
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"`:

```tsx

<section data-theme="dark" className="bg-black text-white py-24">
  <h2>Full-width dark hero</h2>
</section>

<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

| Prop | Type | Description |
| --- | --- | --- |
| 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. |
| menuItems?: | `MenuItem[]` | Navigation menu items for responsive desktop/mobile navigation. Can be sections (expandable dropdowns on desktop, accordions on mobile) or links (direct navigation). At container widths of 65rem (1040px) and wider, renders as horizontal navigation. Below that width, enables the animated full-screen menu overlay. |
| menuCtaButton?: | `React.ReactNode` | CTA button displayed at the bottom of the mobile menu. Typically a primary Button for sign in or sign up actions. |
| menuOpen?: | `boolean` | Controlled mobile menu open state. Use with onMenuOpenChange. |
| 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

| Prop | Type | Description |
| --- | --- | --- |
| 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

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):

| Prop | Type | Description |
| --- | --- | --- |
| 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):

| Prop | Type | Description |
| --- | --- | --- |
| 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):

| Prop | Type | Description |
| --- | --- | --- |
| 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):

| Prop | Type | Description |
| --- | --- | --- |
| 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

```bash
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.

```tsx
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

| Prop | Type | Description |
| --- | --- | --- |
| 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. |