Skip to content
design
Components

Navigation list

Grouped links to the application's pages, with icons, counts and the current page marked, as a panel or an icon rail.

On this page
Switch between panel and rail with the button above the list. Follow a link, close a group or resize the column with its splitter. Expanding restores the chosen width and closed groups.
Code
import { NavList, NavListToggle, NavUser, type NavGroup, type NavItem } from "@bosunkit/components/nav-list";
import { Splitter } from "@bosunkit/components/splitter";
import {
  Activity,
  Anchor,
  BookOpen,
  Cloud,
  Gauge,
  LayoutDashboard,
  ListChecks,
  Map as MapIcon,
  MessageSquare,
  Settings,
  Ship,
  TriangleAlert,
  Users,
  Waves,
  Wrench,
} from "lucide-react";
import { useId, useState, type Dispatch, type MouseEvent, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Use the panel-to-rail button or the splitter to give space back to the page. Expanding restores the panel width, " +
  "current page and closed groups. Scroll the list to reach every destination.";

const GROUPS: readonly NavGroup[] = [
  {
    id: "underway",
    label: "Underway",
    items: [
      { href: "#dashboard", label: "Dashboard", icon: <LayoutDashboard /> },
      {
        href: "#situation",
        label: "Situation",
        icon: <TriangleAlert />,
        count: { value: 1, label: "1 unacknowledged alert", priority: "alarm" },
      },
      { href: "#conning", label: "Conning", icon: <Ship /> },
      { href: "#chart", label: "Chart", icon: <MapIcon />, count: { value: 7, label: "7 targets tracked" } },
      { href: "#instruments", label: "Instruments", icon: <Gauge /> },
      { href: "#anchor", label: "Anchor", icon: <Anchor /> },
    ],
  },
  {
    id: "passage",
    label: "Passage",
    items: [
      { href: "#weather", label: "Weather", icon: <Cloud /> },
      { href: "#tides", label: "Tides", icon: <Waves /> },
      { href: "#log", label: "Log", icon: <BookOpen /> },
      { href: "#checklists", label: "Checklists", icon: <ListChecks /> },
    ],
  },
  {
    id: "aboard",
    label: "Aboard",
    items: [
      { href: "#chat", label: "Chat", icon: <MessageSquare /> },
      { href: "#crew", label: "Crew", icon: <Users />, count: { value: 4, label: "4 people aboard" } },
    ],
  },
  {
    id: "vessel",
    label: "Vessel",
    items: [
      {
        href: "#maintenance",
        label: "Maintenance",
        icon: <Wrench />,
        count: { value: 2, label: "2 tasks due", priority: "caution" },
      },
      { href: "#settings", label: "Settings", icon: <Settings /> },
      { href: "#telemetry", label: "Telemetry", icon: <Activity /> },
    ],
  },
];

// The example owns its columns; NavListToggle and Splitter share the same fold state.
const LAYOUT: string = `
.nav-list-showcase { container: navigation-example / inline-size; width: 100%; }
.nav-list-showcase__frame {
  display: flex;
  height: calc(var(--target-touch-size) * 9);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
}
.nav-list-showcase__column {
  width: var(--nav-list-showcase-width);
  max-width: 100%;
  flex: none;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--surface);
}
.nav-list-showcase__toolbar {
  display: flex;
  justify-content: flex-end;
  padding: var(--space-sm);
  border-bottom: 1px solid var(--border);
}
.nav-list-showcase__scroll {
  overflow: auto;
  flex: 1 1 auto;
  min-height: 0;
  padding: var(--space-sm);
}
.nav-list-showcase__page {
  flex: 1 1 auto;
  min-width: 0;
  padding: var(--space-lg);
}
@container navigation-example (max-width: 28em) {
  .nav-list-showcase__frame[data-collapsed="false"] .nav-list-showcase__column { width: 100%; }
  .nav-list-showcase__frame[data-collapsed="false"] .nav-list-showcase__page { display: none; }
  .nav-list-showcase__splitter { display: none; }
}
`;

export default function Showcase(): ReactElement {
  const columnId: string = useId();
  const [width, setWidth]: [number, Dispatch<SetStateAction<number>>] = useState<number>(16);
  const [folded, setFolded]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const [current, setCurrent]: [string, Dispatch<SetStateAction<string>>] = useState<string>("#situation");
  const [closed, setClosed]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
    readonly string[]
  >(["vessel"]);
  const currentLabel: string =
    GROUPS.flatMap((group: NavGroup): readonly NavItem[] => group.items).find(
      (item: NavItem): boolean => item.href === current,
    )?.label ?? "Dana Whitlock’s profile";

  function follow(event: MouseEvent<HTMLElement>): void {
    if (
      event.defaultPrevented ||
      event.button !== 0 ||
      event.metaKey ||
      event.ctrlKey ||
      event.shiftKey ||
      event.altKey
    )
      return;
    const link: HTMLAnchorElement | null = event.target instanceof Element ? event.target.closest("a") : null;
    const href: string | null | undefined = link?.getAttribute("href");
    if (href?.startsWith("#") === true) {
      event.preventDefault();
      setCurrent(href);
    }
  }

  return (
    <div className="nav-list-showcase">
      <style href="bosunkit-nav-list-showcase" precedence="default">
        {LAYOUT}
      </style>
      <div className="nav-list-showcase__frame" data-collapsed={folded ? "true" : "false"}>
        <nav
          id={columnId}
          aria-label="Example pages"
          onClick={follow}
          className="nav-list-showcase__column"
          style={{ "--nav-list-showcase-width": `${String(folded ? 4.5 : width)}em` }}
        >
          <div className="nav-list-showcase__toolbar">
            <NavListToggle collapsed={folded} controls={columnId} onCollapsedChange={setFolded} />
          </div>
          <div className="nav-list-showcase__scroll">
            <NavList
              groups={GROUPS}
              currentHref={current}
              mode={folded ? "rail" : "panel"}
              closedGroups={closed}
              onClosedGroupsChange={setClosed}
            />
          </div>
          <div style={{ flex: "none", padding: "var(--space-sm)", borderTop: "1px solid var(--border)" }}>
            <NavUser
              name="Dana Whitlock"
              description="Crew coordinator"
              menuLabel="Account actions for Dana Whitlock"
              mode={folded ? "rail" : "panel"}
              actions={[
                { id: "profile", label: "View profile" },
                { id: "preferences", label: "Account preferences" },
              ]}
              onSelect={(action: string): void => {
                setCurrent(action === "profile" ? "#profile" : "#settings");
              }}
            />
          </div>
        </nav>
        <Splitter
          className="nav-list-showcase__splitter"
          label="Resize the navigation"
          controls={columnId}
          panel="start"
          value={width}
          min={11}
          max={22}
          step={1}
          largeStep={4}
          pixelsPerUnit={16}
          collapsible
          collapsed={folded}
          collapsedValue={4.5}
          onCollapsedChange={setFolded}
          onValueChange={setWidth}
        />
        <section className="nav-list-showcase__page" aria-label="Example page">
          <h3 style={{ margin: 0, font: "var(--bk-type-heading-component)" }}>{currentLabel}</h3>
          <p style={{ font: "var(--bk-type-body)", color: "var(--text-dim)" }}>
            Choose a page in the navigation. Its selection stays when you switch between panel and rail.
          </p>
        </section>
      </div>
    </div>
  );
}

The navigation list is the app shell's navigation: the application's pages in groups, each a link with its icon and name, and the page on screen marked with aria-current="page", a raised surface and the accent on its icon. It reads no data and holds no route. The links go through the provider's link component, so ui/web's router handles a path inside the application, and a web address goes to a plain anchor; the caller says which page is current with currentHref.

A destination can carry a count. An alert count takes its priority's color and its IEC 62923 shape before the number, so the color never speaks alone; any other count, such as the targets tracked, is drawn in the accent. Above 99 a count reads 99+, and each is read aloud by its words, so a screen reader hears "Situation, 1 unacknowledged alert".

Each group's heading opens and closes it. A closed group keeps its alert counts on its heading, so an alert waiting on a page the crew folded away stays in sight. The caller can hold which groups are closed (closedGroups), as ui/web keeps them per browser.

The list measures its own column. Narrower than 9em it is a rail: icons in a column, each link named in a tooltip on hover and focus, the counts on the icons' corners, every group shown and ruled off from the next. mode holds the panel or the rail at any width. Every link and heading is at least a touch target tall.

The list sets no width of its own; its column does. NavListToggle requests a change between panel and rail. The column owner updates the width and mode together, keeping the last panel width to restore on expansion. The button stays reachable, has a translated name and tooltip, and exposes aria-expanded and aria-controls. Closing groups and selecting a destination are independent of folding the column.

In the app shell, the navigation column is a rail on a tablet and a panel on a desktop. The desktop panel has this toggle and an optional splitter, both using the same fold state. A phone uses the shell's menu button. The hero demonstrates a standalone column: at narrow widths it gives the panel the available width; folding it reveals the page beside its rail. The list scrolls below its toggle.

01Quiet, so alerts stand outLabels are dim and the current page is marked by a raised surface, never an alert color; only an alert count carries one.
02Folded, never hiddenA closed group or a rail shows where an alert waits, by its count and shape.
03Links, not buttonsEvery destination is a link the router follows, which a browser can open in a new tab and a screen reader lists as a link.

Examples

A closed group

A closed group keeps its alert counts on its heading, with the priority's shape, so a closed Underway still shows the warning waiting on Situation.
Code
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { Anchor, BookOpen, TriangleAlert, Waves } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "A closed group keeps its alert counts on its heading, with the priority's shape, so a closed Underway still " +
  "shows the warning waiting on Situation.";

const GROUPS: readonly NavGroup[] = [
  {
    id: "underway",
    label: "Underway",
    items: [
      {
        href: "#situation",
        label: "Situation",
        icon: <TriangleAlert />,
        count: { value: 2, label: "2 unacknowledged alerts", priority: "warning" },
      },
      { href: "#anchor", label: "Anchor", icon: <Anchor /> },
    ],
  },
  {
    id: "passage",
    label: "Passage",
    items: [
      { href: "#tides", label: "Tides", icon: <Waves /> },
      { href: "#log", label: "Log", icon: <BookOpen /> },
    ],
  },
];

export default function ClosedGroup(): ReactElement {
  return (
    <div style={{ width: "16em", maxWidth: "100%" }}>
      <NavList groups={GROUPS} currentHref="#tides" defaultClosedGroups={["underway"]} />
    </div>
  );
}

As a rail

As a rail: icons in a column, each link named in a tooltip on hover and focus, a count on its icon's corner, and the groups ruled off from each other.
Code
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { Anchor, Cloud, LayoutDashboard, Map as MapIcon, MessageSquare, TriangleAlert } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "As a rail: icons in a column, each link named in a tooltip on hover and focus, a count on its icon's corner, and " +
  "the groups ruled off from each other.";

const GROUPS: readonly NavGroup[] = [
  {
    id: "underway",
    label: "Underway",
    items: [
      { href: "#dashboard", label: "Dashboard", icon: <LayoutDashboard /> },
      {
        href: "#situation",
        label: "Situation",
        icon: <TriangleAlert />,
        count: { value: 1, label: "1 unacknowledged alert", priority: "alarm" },
      },
      { href: "#chart", label: "Chart", icon: <MapIcon /> },
      { href: "#anchor", label: "Anchor", icon: <Anchor /> },
    ],
  },
  {
    id: "aboard",
    label: "Aboard",
    items: [
      { href: "#weather", label: "Weather", icon: <Cloud /> },
      { href: "#chat", label: "Chat", icon: <MessageSquare /> },
    ],
  },
];

export default function Rail(): ReactElement {
  return (
    <div style={{ width: "4.5em" }}>
      <NavList groups={GROUPS} currentHref="#chart" mode="rail" />
    </div>
  );
}

By the column's width

The same list in mode auto in a 4.5em column and a 14em one: under 9em it draws the rail, wider it draws labels.
Code
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { Anchor, Cloud, LayoutDashboard } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "The same list in mode auto in a 4.5em column and a 14em one: under 9em it draws the rail, wider it draws labels.";

const GROUPS: readonly NavGroup[] = [
  {
    id: "pages",
    label: "Underway",
    items: [
      { href: "#dashboard", label: "Dashboard", icon: <LayoutDashboard /> },
      { href: "#anchor", label: "Anchor", icon: <Anchor /> },
      { href: "#weather", label: "Weather", icon: <Cloud /> },
    ],
  },
];

export default function ByColumnWidth(): ReactElement {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-xl)", alignItems: "flex-start" }}>
      <div style={{ width: "4.5em" }}>
        <NavList groups={GROUPS} currentHref="#anchor" />
      </div>
      <div style={{ width: "14em", maxWidth: "100%" }}>
        <NavList groups={GROUPS} currentHref="#anchor" />
      </div>
    </div>
  );
}

Without headings

A group with no label has no heading and does not close: the settings and telemetry at the foot of a rail or a panel.
Code
import { NavList } from "@bosunkit/components/nav-list";
import { Activity, Settings } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "A group with no label has no heading and does not close: the settings and telemetry at the foot of a rail or " +
  "a panel.";

export default function WithoutHeadings(): ReactElement {
  return (
    <div style={{ width: "16em", maxWidth: "100%" }}>
      <NavList
        groups={[
          {
            id: "foot",
            items: [
              { href: "#telemetry", label: "Telemetry", icon: <Activity /> },
              { href: "#settings", label: "Settings", icon: <Settings /> },
            ],
          },
        ]}
      />
    </div>
  );
}

Account and submenu composition

A crew workspace separates application destinations, team selection and local tabs. Filter teams, change the selection, open Dana's account menu and compare the available width with a wide desktop. All records are illustrative.
Code
import { DevicePreview, openingDevice } from "../../../../app/components/hero-preview";
import { AppShell } from "@bosunkit/components/app-shell";
import { Breadcrumbs } from "@bosunkit/components/breadcrumbs";
import { Button } from "@bosunkit/components/button";
import { NavList, NavUser, type NavGroup } from "@bosunkit/components/nav-list";
import { SearchInput } from "@bosunkit/components/search-input";
import { Tabs } from "@bosunkit/components/tabs";
import { BookOpen, Compass, House, Settings, Users, Wrench } from "lucide-react";
import {
  useState,
  type ChangeEvent,
  type Dispatch,
  type MouseEvent,
  type ReactElement,
  type SetStateAction,
} from "react";

export const description: string =
  "A crew workspace separates application destinations, team selection and local tabs. Filter teams, change the selection, open Dana's account menu and compare the available width with a wide desktop. All records are illustrative.";
const groups: readonly NavGroup[] = [
  {
    id: "work",
    label: "Workspace",
    items: [
      { href: "#workspace-home", label: "Overview", icon: <House /> },
      { href: "#workspace-crew", label: "Crew", icon: <Users /> },
      { href: "#workspace-reports", label: "Reports", icon: <BookOpen /> },
    ],
  },
  {
    id: "vessel",
    label: "Vessel",
    items: [
      { href: "#workspace-equipment", label: "Equipment", icon: <Wrench /> },
      { href: "#workspace-preferences", label: "Preferences", icon: <Settings /> },
    ],
  },
];
interface Team {
  id: string;
  label: string;
  detail: string;
  people: readonly string[];
}
const teams: readonly [Team, ...Team[]] = [
  {
    id: "deck",
    label: "Deck team",
    detail: "Navigation and deck operations",
    people: ["Nadia Okafor", "Tom Rivers", "Aline Sousa"],
  },
  { id: "engineering", label: "Engineering", detail: "Machinery and systems", people: ["Marek Kowal", "Priya Menon"] },
  {
    id: "hospitality",
    label: "Hospitality",
    detail: "Guest services and provisioning",
    people: ["Esme Laurent", "Kofi Mensah"],
  },
];
const layout: string = `
.nav-workspace__viewport { max-width: 100%; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); }
.nav-workspace__screen { height: calc(var(--target-touch-size) * 12); }
.nav-workspace__viewport:has([data-wide="true"]) { width: 100%; height: 100%; }
.nav-workspace__screen[data-wide="true"] { height: 100%; }
.nav-workspace__panes { display: grid; grid-template-columns: minmax(0, var(--bk-navigation-submenu-width)) minmax(0, 1fr); min-height: calc(var(--target-touch-size) * 9); }
.nav-workspace__teams { padding-inline-end: var(--space-lg); border-inline-end: 1px solid var(--border); display: grid; align-content: start; gap: var(--space-lg); }
.nav-workspace__content { padding-inline-start: var(--bk-navigation-content-inset); min-width: 0; }
.nav-workspace__people { list-style: none; margin: 0; padding: 0; }
.nav-workspace__people li { padding-block: var(--space-lg); border-bottom: 1px solid var(--border); }
@container bk-screen (max-width: 55em) { .nav-workspace__panes { grid-template-columns: minmax(0, 1fr); gap: var(--space-xl); } .nav-workspace__teams { padding-inline-end: 0; border-inline-end: 0; } .nav-workspace__content { padding-inline-start: 0; } }
`;
export default function Workspace(): ReactElement {
  const [wide, setWide]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
  const [destination, setDestination]: [string, Dispatch<SetStateAction<string>>] = useState("#workspace-crew");
  const [teamId, setTeamId]: [string, Dispatch<SetStateAction<string>>] = useState("deck");
  const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState("");
  const [tab, setTab]: [string, Dispatch<SetStateAction<string>>] = useState("people");
  const [account, setAccount]: [string, Dispatch<SetStateAction<string>>] = useState("");
  const team: Team = teams.find((item: Team): boolean => item.id === teamId) ?? teams[0];
  const shown: readonly Team[] = teams.filter((item: Team): boolean =>
    `${item.label} ${item.detail}`.toLowerCase().includes(query.toLowerCase()),
  );
  const title: string =
    groups
      .flatMap((group: NavGroup): readonly { href: string; label: string }[] => group.items)
      .find((item: { href: string; label: string }): boolean => item.href === destination)?.label ?? "Crew";
  function follow(event: MouseEvent<HTMLDivElement>): void {
    if (
      event.defaultPrevented ||
      event.button !== 0 ||
      event.ctrlKey ||
      event.metaKey ||
      event.shiftKey ||
      event.altKey
    )
      return;
    const link: HTMLAnchorElement | null = event.target instanceof Element ? event.target.closest("a") : null;
    const href: string = link?.getAttribute("href") ?? "";
    if (href.startsWith("#workspace-")) {
      event.preventDefault();
      setDestination(href);
      setAccount("");
    }
    if (href.startsWith("#team-")) {
      event.preventDefault();
      setTeamId(href.slice(6));
      setTab("people");
    }
  }
  const screen: ReactElement = (
    <div className="nav-workspace__viewport" onClick={follow}>
      <div className="nav-workspace__screen" data-wide={wide}>
        <AppShell
          fillHeight
          defaultNavigationWidth={18}
          resizable={false}
          navigationLabel="Workspace destinations"
          header={
            <div style={{ padding: "var(--space-lg)", font: "var(--bk-type-label)" }}>Bosun · Crew workspace</div>
          }
          navigation={<NavList groups={groups} currentHref={destination} collapsible={false} />}
          navigationFooter={
            <NavUser
              name="Dana Whitlock"
              description="Crew coordinator"
              menuLabel="Dana Whitlock account menu"
              avatar={<span>DW</span>}
              actions={[
                { id: "profile", label: "View profile", icon: <Users /> },
                { id: "preferences", label: "Account preferences", icon: <Settings /> },
              ]}
              onSelect={(action: string): void => {
                setAccount(
                  action === "profile" ? "Dana Whitlock · Crew coordinator" : "Account preferences · Local preview",
                );
              }}
            />
          }
          main={
            account !== "" ? (
              <section>
                <h3>{account}</h3>
                <Button
                  onClick={(): void => {
                    setAccount("");
                  }}
                >
                  Back to workspace
                </Button>
              </section>
            ) : destination !== "#workspace-crew" ? (
              <section>
                <h3>{title}</h3>
                <p>
                  This local example shows the {title.toLowerCase()} destination. Choose Crew to explore its team
                  submenu.
                </p>
              </section>
            ) : (
              <div className="nav-workspace__panes">
                <section className="nav-workspace__teams" aria-label="Team submenu">
                  <h3 style={{ margin: 0, font: "var(--bk-type-heading-component)" }}>All teams</h3>
                  <SearchInput
                    aria-label="Filter teams"
                    placeholder="Filter teams"
                    value={query}
                    onChange={(event: ChangeEvent<HTMLInputElement>): void => {
                      setQuery(event.currentTarget.value);
                    }}
                    onClear={(): void => {
                      setQuery("");
                    }}
                    clearLabel="Clear team filter"
                  />
                  <nav aria-label="Teams">
                    <NavList
                      mode="panel"
                      currentHref={`#team-${teamId}`}
                      groups={[
                        {
                          id: "teams",
                          items: shown.map(
                            (item: Team): { href: string; label: string; description: string; icon: ReactElement } => ({
                              href: `#team-${item.id}`,
                              label: item.label,
                              description: item.detail,
                              icon: <Compass />,
                            }),
                          ),
                        },
                      ]}
                    />
                  </nav>
                  {shown.length === 0 && <p role="status">No matching teams. Clear the filter to see all teams.</p>}
                </section>
                <section className="nav-workspace__content" aria-label="Team content">
                  <Breadcrumbs
                    label="Team hierarchy"
                    expandLabel="Show ancestors"
                    collapseLabel="Hide ancestors"
                    items={[
                      { id: "crew", label: "Crew", href: "#workspace-crew" },
                      { id: team.id, label: team.label },
                    ]}
                  />
                  <h3>{team.label}</h3>
                  <p>{team.detail}</p>
                  <Tabs
                    tabs={[
                      { id: "people", label: "People" },
                      { id: "responsibilities", label: "Responsibilities" },
                    ]}
                    value={tab}
                    onValueChange={setTab}
                  >
                    <Tabs.Panel value="people">
                      <ul className="nav-workspace__people">
                        {team.people.map((name: string): ReactElement => (
                          <li key={name}>{name}</li>
                        ))}
                      </ul>
                    </Tabs.Panel>
                    <Tabs.Panel value="responsibilities">
                      <p>{team.detail}. Team assignments in this preview are illustrative.</p>
                    </Tabs.Panel>
                  </Tabs>
                </section>
              </div>
            )
          }
        />
      </div>
    </div>
  );
  return (
    <div style={{ width: "100%", display: "grid", gap: "var(--space-md)" }}>
      <style href="bosunkit-nav-workspace" precedence="default">
        {layout}
      </style>
      <div>
        <Button
          variant="ghost"
          onClick={(): void => {
            setWide(!wide);
          }}
        >
          Preview wide desktop
        </Button>
      </div>
      {wide ? (
        <DevicePreview
          initial={openingDevice("desktop")}
          background="var(--bg)"
          controls={null}
          onClose={(): void => {
            setWide(false);
          }}
        >
          {screen}
        </DevicePreview>
      ) : (
        screen
      )}
    </div>
  );
}

NavUser shows the current user's avatar, name and a supporting role or organization. The whole card opens a named account menu; there is one focus target, with no nested button inside another button. The caller supplies translated actions and handles their effects. The rail keeps the avatar and accessible name; opening its menu shows the full identity. A generic user mark is the fallback when no avatar is supplied.

Place it in the app shell's navigationFooter, outside the scrolling destinations. In a standalone column, use one flexible scroll region for links and a separate, nonshrinking footer. Avoid fixed positioning against the browser window: a navigation column can live inside an embedded application or a smaller device preview.

A destination's optional description supports a secondary navigation column, such as a team and its remit. Keep primary destinations, secondary selection and local tabs as separate regions with distinct accessible names. The wide workspace example shows all three; at narrow widths the team selector precedes its content.

Spacing and hierarchy

The list uses --bk-navigation-group-gap between groups, --bk-navigation-item-gap between destinations and --bk-navigation-item-inset inside each row. Group spacing is deliberately larger than row spacing. Collapsible headings retain their touch target; a static heading uses the compact Section heading role.

Column width belongs to the shell. The default app shell uses its compact navigation width; the wide workspace uses --bk-navigation-column-width and --bk-navigation-submenu-width as its composition roles. The content inset after the secondary divider uses --bk-navigation-content-inset. A divider marks the boundary; content padding creates the breathing room on its other side. Avoid stacking several untracked margins at that edge.

TokenValueremPreview
--bk-navigation-group-gapvar(--space-xl)——
--bk-navigation-item-gapvar(--space-xs)——
--bk-navigation-item-insetvar(--space-md)——
--bk-navigation-avatar-size2em——
--bk-navigation-menu-width16em——
--bk-navigation-column-width18em——
--bk-navigation-submenu-width18em——
--bk-navigation-content-insetvar(--space-xl)——
--bk-navigation-icon-size1.25em——
--bk-navigation-line-width1px0.0625rem

Props

PropTypeDefaultDescription
groupsrequiredreadonly NavGroup[]–The destinations, by group.
currentHrefstring | undefined–The href of the page on screen; its link carries aria-current="page".
modeNavListMode | undefined"auto"auto (the default) draws an icon rail while the list is narrower than 9em and labels when it is wider, so the column the app shell gives it decides; panel and rail hold one form at every width. In the rail the labels are hidden, still read aloud, and shown in a tooltip; the group headings give way to a rule and every group shows its destinations.
headingLevelNestedHeadingLevel | undefined2The level of the group headings, so they sit in the page's outline; 2 by default.
collapsibleboolean | undefinedtrueWhether a group's heading opens and closes the group; on by default.
closedGroupsreadonly string[] | undefined–The ids of the closed groups, for a caller that holds them (ui/web stores them per browser).
defaultClosedGroupsreadonly string[] | undefined[]The groups closed at first, when the list holds the state itself.
onClosedGroupsChange((closed: readonly string[]) => void) | undefined–Called with the closed groups' ids when the crew open or close one.
classNamestring | undefined–Classes added to the root's own.
testIdstring | undefined–Becomes data-testid on the root; a group's heading button is ${testId}--toggle-<group id>.
refRef<HTMLDivElement> | undefined–The root element.

The column owner passes collapsed, onCollapsedChange and the controlled region's id as controls. The button uses the shared icon-button target, focus treatment and tooltip. It requests state changes; it does not resize a region by itself.

PropTypeDefaultDescription
variantButtonVariant | undefined"ghost"The button's emphasis: primary for the main action, danger for a destructive one, ghost for a quiet one.
textCaseButtonTextCase | undefined–How the label is cased: in capitals by default. none keeps the letters as written, for a label whose case carries meaning, such as a chess move in SAN (Nf3, exd5), a call sign or a chart number.
motionMotionProp | undefined–How the button moves. By default it fades its new label in when the label changes ("Acknowledge" to "Acknowledged"), keeping its focus, and has no entrance. true also scales it in as it mounts, wherever it is, and out when it leaves a MotionPresence; a preset (fade, rise, scale) or { enter, exit, change } chooses; false turns it off. It is named and pressable from the first frame.
refRef<HTMLButtonElement> | undefined–The button element.
shortcutstring | undefined–A keyboard shortcut shown after the label in a key chip, such as Ctrl K, when the control has one.
testIdstring | undefined–Becomes data-testid on the tooltip's trigger, which is the button.
collapsedrequiredboolean–Whether the navigation column is folded to its icon rail.
controlsrequiredstring–The id of the navigation region whose column changes width.
onCollapsedChangerequired(collapsed: boolean) => void–Requests a fold or expansion; the column owner updates its width and the list's mode together.

Also takes every attribute of Omit< IconButtonProps, "label" | "icon" | "onClick" | "aria-expanded" | "aria-controls" >.

PropTypeDefaultDescription
namerequiredstring–The current user's display name.
descriptionstring | undefined–A role, organization or other short identity detail.
avatarReactNode | undefined–Decorative avatar, such as an image with empty alt text; a generic user icon is the fallback.
menuLabelrequiredstring–Complete translated trigger and menu name, including the user's name.
actionsrequiredreadonly NavUserAction[]–Available account actions. An empty list disables the trigger.
onSelectrequired(id: string) => void–Requests an action; it never signs out, changes an account or navigates by itself.
modeNavListMode | undefined"auto"Auto follows the available column width; panel and rail force a form.
openboolean | undefined–Controlled menu visibility.
defaultOpenboolean | undefined–Initial uncontrolled menu visibility.
onOpenChange((open: boolean) => void) | undefined–Reports menu opening and dismissal.
classNamestring | undefined–Additional wrapper classes.
testIdstring | undefined–Wrapper test id; trigger and menu use --trigger and --menu suffixes.
refRef<HTMLButtonElement> | undefined–The account trigger.
Do
Count alerts with their priority's shape and word, so the count reads without its color.
Don't
Don't count data in an alert color. Seven targets tracked is not a warning, and a yellow count says it is.
  • Action menu: the shared menu used by account controls.

  • App shell: the navigation slot, a rail on a tablet and a panel on a desktop.

  • Navigation bar: the phone's navigation at the foot of the screen.

  • Navigation: choosing hierarchy, page, collection and task navigation.

  • Command palette: direct lookup of destinations and actions.