Skip to content
design
Components

Navigation bar

The phone's navigation at the foot of the screen, three to five places with an icon and a short name, the current one marked.

On this page

Holding 18 m from the drop point. Wind southwest 18 kn, gusts 24 kn.

A phone at anchor with the anchor drag alarm counted on Alerts. Press a place and it becomes current; More opens every other page above the bar.
Code
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { NavigationBar, type NavigationBarItem } from "@bosunkit/components/navigation-bar";
import {
  BookOpen,
  Cloud,
  House,
  Map as MapIcon,
  Menu,
  MessageSquare,
  Settings,
  TriangleAlert,
  Users,
  Waves,
} from "lucide-react";
import { useState, type Dispatch, type MouseEvent, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A phone at anchor: Home, Chart, Alerts with the unacknowledged alarm counted, Chat, and More, which opens every " +
  "other page above the bar. Press a place and it becomes current.";

const PAGES: Readonly<Record<string, string>> = {
  "#home": "Holding 18 m from the drop point. Wind southwest 18 kn, gusts 24 kn.",
  "#chart": "Morgat anchorage with 3 AIS targets in range.",
  "#alerts": "Anchor dragging: 41 m from the drop point, outside the 35 m swing radius.",
  "#chat": "Ask bosun about the anchor, the weather or the tides.",
};

const MORE: readonly NavGroup[] = [
  {
    id: "passage",
    label: "Passage",
    items: [
      { href: "#weather", label: "Weather", icon: <Cloud /> },
      { href: "#tides", label: "Tides", icon: <Waves /> },
      { href: "#log", label: "Log", icon: <BookOpen /> },
    ],
  },
  {
    id: "aboard",
    label: "Aboard",
    items: [
      { href: "#crew", label: "Crew", icon: <Users /> },
      { href: "#settings", label: "Settings", icon: <Settings /> },
    ],
  },
];

export default function Showcase(): ReactElement {
  const [current, setCurrent]: [string, Dispatch<SetStateAction<string>>] = useState<string>("#home");
  const [moreOpen, setMoreOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);

  // The example stands in for the router: following a link makes its page current and closes More.
  function follow(event: MouseEvent<HTMLDivElement>): void {
    const link: HTMLAnchorElement | null = event.target instanceof Element ? event.target.closest("a") : null;
    if (link !== null) {
      event.preventDefault();
      setCurrent(link.getAttribute("href") ?? current);
      setMoreOpen(false);
    }
  }

  const items: readonly NavigationBarItem[] = [
    { id: "#home", label: "Home", icon: <House />, href: "#home" },
    { id: "#chart", label: "Chart", icon: <MapIcon />, href: "#chart" },
    {
      id: "#alerts",
      label: "Alerts",
      icon: <TriangleAlert />,
      href: "#alerts",
      count: { value: 1, label: "1 unacknowledged alert", priority: "alarm" },
    },
    { id: "#chat", label: "Chat", icon: <MessageSquare />, href: "#chat" },
    {
      id: "more",
      label: "More",
      icon: <Menu />,
      expanded: moreOpen,
      onSelect: (): void => {
        setMoreOpen(!moreOpen);
      },
    },
  ];
  const page: string | undefined = Object.hasOwn(PAGES, current) ? PAGES[current] : undefined;

  return (
    <div
      onClick={follow}
      style={{
        display: "flex",
        flexDirection: "column",
        width: "390px",
        maxWidth: "100%",
        minHeight: "22em",
        border: "1px solid var(--border)",
      }}
    >
      <p style={{ flex: "1 1 auto", margin: 0, padding: "var(--space-lg)" }}>
        {page ?? `The ${current.slice(1)} page.`}
      </p>
      {moreOpen && (
        <nav aria-label="More pages" style={{ padding: "var(--space-sm)", borderTop: "1px solid var(--border)" }}>
          <NavList groups={MORE} currentHref={current} mode="panel" collapsible={false} />
        </nav>
      )}
      <NavigationBar items={items} current={page === undefined ? "more" : current} />
    </div>
  );
}

The tab bar is the phone's navigation, at the foot of the screen where a thumb reaches: three to five places, each an icon over a short name. The app shell shows it in place of the navigation column when it is phone-wide, sticky at the foot. It is a navigation landmark named "Main navigation" in the screen's language, or by label.

A place with an href is a link through the provider's link component; a place without one is a button, such as More, which opens the full navigation and says whether it is open with aria-expanded. The current place, a link or a button, carries aria-current="page" and takes the accent, never an alert color. A count, such as the unacknowledged alerts, sits on the icon's corner in its priority's color and shape, and is read by its words.

The places share the width equally, each at least a touch target tall, and the bar pads itself clear of a phone's rounded corners and home indicator with the safe-area insets.

01Where the thumb isThe places a crew member goes most sit at the foot of a phone, a thumb's reach from where it is held on a moving vessel.
02Few places, one word eachThree to five places, each named in a word. Everything else goes behind More.
03The alert count travels with the crewWhatever page is open, the Alerts place shows how many alerts wait, with their priority's shape.

Examples

Three places

Three places, the fewest a tab bar holds: each shares the width equally and is a thumb's target tall.
Code
import { NavigationBar } from "@bosunkit/components/navigation-bar";
import { Anchor, House, MessageSquare } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "Three places, the fewest a tab bar holds: each shares the width equally and is a thumb's target tall.";

export default function ThreePlaces(): ReactElement {
  return (
    <div style={{ width: "390px", maxWidth: "100%" }}>
      <NavigationBar
        current="anchor"
        items={[
          { id: "home", label: "Home", icon: <House />, href: "#home" },
          { id: "anchor", label: "Anchor", icon: <Anchor />, href: "#anchor" },
          { id: "chat", label: "Chat", icon: <MessageSquare />, href: "#chat" },
        ]}
      />
    </div>
  );
}

An alert count

Two warnings waiting: the count sits on the Alerts icon in the warning color with its shape, and is read as “2 unacknowledged alerts”.
Code
import { NavigationBar } from "@bosunkit/components/navigation-bar";
import { House, Map as MapIcon, MessageSquare, TriangleAlert } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "Two warnings waiting: the count sits on the Alerts icon in the warning color with its shape, and is read as " +
  "“2 unacknowledged alerts”.";

export default function AlertCount(): ReactElement {
  return (
    <div style={{ width: "390px", maxWidth: "100%" }}>
      <NavigationBar
        current="home"
        items={[
          { id: "home", label: "Home", icon: <House />, href: "#home" },
          { id: "chart", label: "Chart", icon: <MapIcon />, href: "#chart" },
          {
            id: "alerts",
            label: "Alerts",
            icon: <TriangleAlert />,
            href: "#alerts",
            count: { value: 2, label: "2 unacknowledged alerts", priority: "warning" },
          },
          { id: "chat", label: "Chat", icon: <MessageSquare />, href: "#chat" },
        ]}
      />
    </div>
  );
}

Props

PropTypeDefaultDescription
itemsrequiredreadonly NavigationBarItem[]–The places, three to five, in order.
currentstring | undefined–The id of the item for the page on screen; it carries aria-current="page".
labelstring | undefined–The navigation landmark's name; "Main navigation" in the screen's language when unset.
classNamestring | undefined–Classes added to the root's own.
testIdstring | undefined–Becomes data-testid on the root; each item is ${testId}--<id>.
refRef<HTMLElement> | undefined–The root element.
Do
Keep to five places, each one word, and put the rest behind More.
Don't
Don't squeeze more places in. Their names cut off and each target narrows below a thumb's width.
  • Navigation: combine primary destinations with page-level navigation.

  • App shell: where the tab bar takes over from the navigation column.

  • Navigation list: every page, behind More.

  • Mobile: the phone screen with its tab bar.