---
title: PageNavigation
description: A responsive page-level navigation bar where the active item is highlighted with an animated black pill. On desktop, all tabs display their text label. On mobile, only the active tab shows its label; inactive tabs show an icon instead.
---

```tsx
'use client';

import { useState } from 'react';
import { PageNavigation } from '@/ui/PageNavigation';

const items = [
  { id: 'accommodation', label: 'Accommodation', href: '#accommodation', icon: 'Home' as const },
  { id: 'transport', label: 'Transport', href: '#transport', icon: 'PlaneDefault' as const },
  { id: 'people', label: 'People', href: '#people', icon: 'PeopleSingle' as const },
  { id: 'activities', label: 'Activities', href: '#activities', icon: 'Multisports' as const },
];

export function PageNavigationDemo() {
  const [active, setActive] = useState('accommodation');

  return (
    <nav
      aria-label="Demo wrapper"
      onClick={(e) => {
        const anchor = (e.target as HTMLElement).closest('a');
        if (anchor) {
          e.preventDefault();
          const id = anchor.getAttribute('href')?.replace('#', '');
          if (id) setActive(id);
        }
      }}
    >
      <PageNavigation items={items} activeItem={active} />
    </nav>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/page-navigation.json
```

Or manually, copy the following code into `/ui/PageNavigation.tsx`:

```tsx
'use client';

import type {
  ComponentPropsWithoutRef,
  CSSProperties,
  FunctionComponent,
  KeyboardEvent,
} from 'react';
import { useCallback, useRef } from 'react';
import clsx from 'clsx';
import { Icon, type IconicNames } from '@clubmed/trident-icons';

export interface PageNavigationItem {
  id: string;
  label: string;
  href: string;
  icon?: IconicNames;
}

export interface PageNavigationProps extends ComponentPropsWithoutRef<'nav'> {
  items: PageNavigationItem[];
  activeItem: string;
}

// Static — hoisted outside component so it's not recreated on every render
const PILL_STYLE: CSSProperties = {
  positionAnchor: '--page-nav-active',
  top: 'anchor(top)',
  bottom: 'anchor(bottom)',
  left: 'anchor(left)',
  right: 'anchor(right)',
  position: 'absolute',
};

const ACTIVE_ANCHOR_STYLE: CSSProperties = { anchorName: '--page-nav-active' };

const PILL = (
  <span
    aria-hidden="true"
    className="pointer-events-none absolute rounded-pill bg-black transition-all duration-300 [position-anchor:--page-nav-active]"
    style={PILL_STYLE}
  />
);

export const PageNavigation: FunctionComponent<PageNavigationProps> = ({
  items,
  activeItem,
  className,
  ...attrs
}) => {
  const listRef = useRef<HTMLUListElement>(null);

  const handleKeyDown = useCallback((e: KeyboardEvent<HTMLUListElement>) => {
    const links = Array.from(listRef.current?.querySelectorAll<HTMLAnchorElement>('a') ?? []);
    const focused = links.indexOf(document.activeElement as HTMLAnchorElement);
    if (focused === -1) return;

    let next = focused;
    if (e.key === 'ArrowRight') next = (focused + 1) % links.length;
    else if (e.key === 'ArrowLeft') next = (focused - 1 + links.length) % links.length;
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = links.length - 1;
    else return;

    e.preventDefault();
    links[next].focus();
  }, []);

  return (
    <nav aria-label="Page navigation" {...attrs} className={clsx('flex items-center', className)}>
      <ul
        ref={listRef}
        onKeyDown={handleKeyDown}
        className="relative flex items-center gap-4 md:gap-16"
        role="list"
      >
        {PILL}

        {items.map((item) => {
          const isActive = item.id === activeItem;

          return (
            <li key={item.id} className="relative">
              <a
                href={item.href}
                aria-current={isActive ? 'page' : undefined}
                style={isActive ? ACTIVE_ANCHOR_STYLE : undefined}
                className={clsx(
                  'relative z-10 grid items-center justify-center gap-2 rounded-pill',
                  'font-sans text-b3 font-semibold focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2 outline-none',
                  isActive ? '[@supports(not(anchor-name:--x))]:bg-black' : 'focus:bg-pearl',
                  isActive ? 'px-20 py-12' : 'md:px-20 md:py-12',
                  isActive ? 'text-white' : 'text-black',
                )}
              >
                {item.icon && (
                  <div
                    className={clsx(
                      'row-start-1 row-end-1 col-start-1 col-end-1 flex items-center justify-center transition-opacity duration-300 md:hidden',
                      'md:opacity-0 transition-opacity mx-auto',
                      isActive && 'opacity-0',
                    )}
                    aria-hidden="true"
                  >
                    <Icon name={item.icon} width="30px" />
                  </div>
                )}

                <span
                  className={clsx(
                    'row-start-1 row-end-1 col-start-1 col-end-1 grid grid-cols-[0fr] md:grid-cols-[1fr] overflow-hidden transition-all opacity-0 duration-300 md:opacity-100 text-center',
                    isActive && 'grid-cols-[1fr] opacity-100',
                  )}
                >
                  <span className="overflow-hidden mx-auto">{item.label}</span>
                </span>
              </a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
};
```

## Usage

```tsx showLineNumbers
import { PageNavigation } from '@/ui/PageNavigation';

const items = [
  { id: 'accommodation', label: 'Accommodation', href: '/accommodation', icon: 'Home' },
  { id: 'transport', label: 'Transport', href: '/transport', icon: 'PlaneDefault' },
  { id: 'activities', label: 'Activities', href: '/activities', icon: 'Multisports' },
];

export function Component() {
  return <PageNavigation items={items} activeItem="accommodation" />;
}
```

## Responsive behaviour

| Viewport | Active item | Inactive items |
| -------- | ----------- | -------------- |
| Mobile (`< md`) | Black pill + label | Icon only (if `icon` provided) |
| Desktop (`≥ md`) | Black pill + label | Label only |

## API Reference

### PageNavigation

| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| `items` | `PageNavigationItem[]` | Required | Ordered list of navigation items. |
| `activeItem` | `string` | Required | The `id` of the currently active item. Controls the black pill indicator position and which label/icon is shown on mobile. |
| `className` | `string` | — | Additional CSS classes applied to the `<nav>` element. |

### PageNavigationItem

| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `id` | `string` | Required | Unique identifier used to match against `activeItem`. |
| `label` | `string` | Required | Human-readable label displayed in the tab. |
| `href` | `string` | Required | URL the tab links to. Rendered as a native `<a>` element. |
| `icon` | `IconicNames` | — | Optional trident-icons icon name shown on mobile for inactive tabs. When omitted, the item shows nothing on mobile while inactive. |

## Notes

- Each item is a native `<a>` link — no JavaScript navigation is required for basic usage. Pass `onClick` to intercept navigation in a SPA context.
- The active item is marked with `aria-current="page"` for screen readers.
- The black pill is absolutely positioned behind the link text using `z-index`, ensuring the link remains fully interactive.
- The pill uses `transition-all duration-300` — for a sliding animation across tabs, integrate a shared animated indicator using CSS or a motion library such as `motion/react`.
- Icon names must be valid `IconicNames` from `@clubmed/trident-icons`. Common choices for navigation: `Home`, `PlaneDefault`, `PeopleSingle`, `Multisports`, `CalendarDefault`, `Search`.
- The component extends `ComponentPropsWithoutRef<'nav'>` so all standard HTML `nav` attributes (`id`, `style`, `data-*`, etc.) are forwarded.
