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.
npx shadcn@latest add @paragon/breadcrumb-menuAlso installs: dropdown-menu
"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>
);
}