Breadcrumb trail that collapses deep paths into a dropdown, with underlines that slide in via background-size.
npx shadcn@latest add @paragon/breadcrumbs"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { ChevronRight, Ellipsis } from "lucide-react";
import { cn } from "@/lib/utils";
export interface BreadcrumbEntry {
label: string;
href?: string;
}
export interface BreadcrumbsProps extends React.ComponentProps<"nav"> {
items: BreadcrumbEntry[];
/** Crumb count above which the middle collapses into a "…" dropdown. */
maxItems?: number;
}
const linkClass =
"rounded-sm bg-[linear-gradient(currentColor,currentColor)] bg-no-repeat [background-position:0_calc(100%-1px)] [background-size:0%_1px] text-muted-foreground transition-[color,background-size] duration-200 ease-out hover:[background-size:100%_1px] hover:text-foreground motion-reduce:transition-[color]";
/* Static keyframes + static href: React hoists and dedupes one <style> tag
* into <head> for every Breadcrumbs on the page (SPEC rule 11). */
const breadcrumbsStyles = `
@keyframes pg-breadcrumbs-in {
from { opacity: 0; transform: scale(0.97) translateY(-2px); }
}
@keyframes pg-breadcrumbs-out {
to { opacity: 0; }
}
[data-slot="breadcrumbs-menu"][data-state="open"] {
animation: pg-breadcrumbs-in 150ms var(--ease-out);
}
[data-slot="breadcrumbs-menu"][data-state="closed"] {
animation: pg-breadcrumbs-out 100ms var(--ease-exit) forwards;
}
@media (prefers-reduced-motion: reduce) {
[data-slot="breadcrumbs-menu"] { animation-duration: 1ms !important; }
}
`;
function Separator() {
return (
<ChevronRight aria-hidden className="size-3.5 shrink-0 text-muted-foreground/50" />
);
}
/**
* Breadcrumb trail where deep paths collapse their middle into a "…"
* dropdown. Links reveal an underline that slides in from the left by
* animating background-size on a 1px gradient; the current page renders
* as plain text with aria-current.
*/
export function Breadcrumbs({
items,
maxItems = 4,
className,
...props
}: BreadcrumbsProps) {
const collapse = items.length > maxItems && items.length > 3;
const head = collapse ? [items[0]] : items.slice(0, -1);
const hidden = collapse ? items.slice(1, items.length - 2) : [];
const tail = collapse ? items.slice(-2, -1) : [];
const current = items[items.length - 1];
const renderCrumb = (item: BreadcrumbEntry, withSeparator: boolean) => (
<li key={item.label} className="flex items-center gap-1.5">
{withSeparator && <Separator />}
{item.href ? (
<a href={item.href} className={linkClass}>
{item.label}
</a>
) : (
<span className="text-muted-foreground">{item.label}</span>
)}
</li>
);
return (
<nav
aria-label="Breadcrumb"
data-slot="breadcrumbs"
className={cn("min-w-0", className)}
{...props}
>
<style href="paragon-breadcrumbs" precedence="paragon">
{breadcrumbsStyles}
</style>
<ol className="flex flex-wrap items-center gap-1.5 text-sm">
{head.map((item, i) => renderCrumb(item, i > 0))}
{collapse && (
<li className="flex items-center gap-1.5">
<Separator />
<DropdownMenuPrimitive.Root>
<DropdownMenuPrimitive.Trigger
aria-label="Show hidden breadcrumbs"
className="relative flex h-5 w-6 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color,scale] duration-150 ease-out after:absolute after:-inset-x-2 after:-inset-y-2.5 hover:bg-accent hover:text-foreground active:scale-[0.97] data-[state=open]:bg-accent data-[state=open]:text-foreground"
>
<Ellipsis className="size-3.5" />
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="breadcrumbs-menu"
align="start"
sideOffset={6}
style={{
transformOrigin:
"var(--radix-dropdown-menu-content-transform-origin)",
}}
className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay"
>
{hidden.map((item) => (
<DropdownMenuPrimitive.Item key={item.label} asChild>
<a
href={item.href}
className="flex h-8 cursor-pointer items-center rounded-md px-2 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
{item.label}
</a>
</DropdownMenuPrimitive.Item>
))}
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
</li>
)}
{tail.map((item) => renderCrumb(item, true))}
<li className="flex min-w-0 items-center gap-1.5">
{items.length > 1 && <Separator />}
<span
aria-current="page"
className="truncate font-medium text-foreground"
>
{current.label}
</span>
</li>
</ol>
</nav>
);
}