Breadcrumb Menu
Navigation

Breadcrumb Menu

Breadcrumbs whose levels are switchers — crumbs with siblings open a checked dropdown of that level's pages, and long trails collapse their middle into an ellipsis menu.

Install

npx shadcn@latest add @paragon/breadcrumb-menu

Also installs: dropdown-menu

breadcrumb-menu.tsx

"use client";

import * as React from "react";
import { Check, ChevronsUpDown, Ellipsis } from "lucide-react";
import { cn } from "@/lib/utils";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";

export interface BreadcrumbMenuSibling {
  label: string;
  href?: string;
  /** Marks the sibling that is the current selection at this level. */
  current?: boolean;
  onSelect?: () => void;
}

export interface BreadcrumbMenuItem {
  label: string;
  href?: string;
  onSelect?: () => void;
  /** Sibling pages at this level — the crumb becomes a switcher menu. */
  siblings?: BreadcrumbMenuSibling[];
}

export interface BreadcrumbMenuProps extends React.ComponentProps<"nav"> {
  items: BreadcrumbMenuItem[];
  /** Collapse middle crumbs into an ellipsis menu past this many. */
  maxVisible?: number;
  /** Node rendered between crumbs. Defaults to a slash. */
  separator?: React.ReactNode;
}

const crumbClasses = cn(
  "relative flex h-7 max-w-40 items-center gap-1 rounded-md px-1.5 text-sm whitespace-nowrap outline-none select-none",
  "text-muted-foreground transition-colors duration-150 ease-out",
  "after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
  "hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
  "data-[state=open]:bg-accent data-[state=open]:text-foreground",
);

function SiblingMenu({
  item,
  isCurrent,
}: {
  item: BreadcrumbMenuItem;
  isCurrent: boolean;
}) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        aria-label={`Switch ${item.label}`}
        aria-current={isCurrent ? "page" : undefined}
        className={cn(crumbClasses, isCurrent && "font-medium text-foreground")}
      >
        <span className="truncate">{item.label}</span>
        <ChevronsUpDown aria-hidden className="size-3 shrink-0 opacity-60" />
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="w-52">
        {(item.siblings ?? []).map((sibling) => (
          <DropdownMenuItem
            key={sibling.label}
            onSelect={() => sibling.onSelect?.()}
            asChild={Boolean(sibling.href)}
          >
            {sibling.href ? (
              <a href={sibling.href}>
                <span className="min-w-0 flex-1 truncate">{sibling.label}</span>
                {sibling.current && <Check aria-hidden className="ml-auto" />}
              </a>
            ) : (
              <>
                <span className="min-w-0 flex-1 truncate">{sibling.label}</span>
                {sibling.current && <Check aria-hidden className="ml-auto" />}
              </>
            )}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

function Crumb({
  item,
  isCurrent,
}: {
  item: BreadcrumbMenuItem;
  isCurrent: boolean;
}) {
  if (item.siblings && item.siblings.length > 0) {
    return <SiblingMenu item={item} isCurrent={isCurrent} />;
  }
  if (isCurrent) {
    return (
      <span
        aria-current="page"
        className="flex h-7 max-w-40 items-center px-1.5 text-sm font-medium whitespace-nowrap text-foreground"
      >
        <span className="truncate">{item.label}</span>
      </span>
    );
  }
  if (item.href) {
    return (
      <a href={item.href} className={crumbClasses}>
        <span className="truncate">{item.label}</span>
      </a>
    );
  }
  return (
    <button type="button" onClick={item.onSelect} className={crumbClasses}>
      <span className="truncate">{item.label}</span>
    </button>
  );
}

/**
 * Path breadcrumbs where levels are switchers, not just links: crumbs with
 * siblings open a dropdown of the pages at that level (current one checked),
 * and when the trail runs long the middle collapses into an ellipsis menu.
 * Menus inherit the house origin-aware physics from dropdown-menu; every
 * crumb is a real link or button with a full-height hit area, and the leaf
 * carries aria-current="page".
 */
export function BreadcrumbMenu({
  items,
  maxVisible = 4,
  separator,
  className,
  ...props
}: BreadcrumbMenuProps) {
  const collapse = items.length > maxVisible && maxVisible >= 3;
  const tailCount = collapse ? maxVisible - 2 : items.length - 1;
  const hidden = collapse ? items.slice(1, items.length - tailCount) : [];
  const visible = collapse
    ? [items[0]!, ...items.slice(items.length - tailCount)]
    : items;

  const sep = (key: string) => (
    <li key={key} aria-hidden className="select-none text-muted-foreground/50">
      {separator ?? <span className="px-0.5 text-[13px]">/</span>}
    </li>
  );

  const nodes: React.ReactNode[] = [];
  visible.forEach((item, position) => {
    const originalIndex = items.indexOf(item);
    if (position > 0) nodes.push(sep(`sep-${originalIndex}`));
    // The ellipsis menu sits after the first crumb.
    if (collapse && position === 1) {
      nodes.push(
        <li key="overflow">
          <DropdownMenu>
            <DropdownMenuTrigger
              aria-label={`Show ${hidden.length} hidden levels`}
              className={cn(crumbClasses, "px-1")}
            >
              <Ellipsis aria-hidden className="size-4" />
            </DropdownMenuTrigger>
            <DropdownMenuContent align="start" className="w-52">
              {hidden.map((hiddenItem) => (
                <DropdownMenuItem
                  key={hiddenItem.label}
                  onSelect={() => hiddenItem.onSelect?.()}
                  asChild={Boolean(hiddenItem.href)}
                >
                  {hiddenItem.href ? (
                    <a href={hiddenItem.href}>{hiddenItem.label}</a>
                  ) : (
                    <>{hiddenItem.label}</>
                  )}
                </DropdownMenuItem>
              ))}
            </DropdownMenuContent>
          </DropdownMenu>
        </li>,
        sep(`sep-overflow`),
      );
    }
    nodes.push(
      <li key={`${item.label}-${originalIndex}`} className="min-w-0">
        <Crumb item={item} isCurrent={originalIndex === items.length - 1} />
      </li>,
    );
  });

  return (
    <nav
      data-slot="breadcrumb-menu"
      aria-label="Breadcrumb"
      className={cn("min-w-0", className)}
      {...props}
    >
      <ol className="flex min-w-0 items-center gap-0.5">{nodes}</ol>
    </nav>
  );
}