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";
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 id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
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" className={cn("min-w-0", className)} {...props}>
<style href={`paragon-breadcrumbs-${id}`} precedence="paragon">{`
@keyframes bc-in-${id} {
from { opacity: 0; transform: scale(0.97) translateY(-2px); }
}
@keyframes bc-out-${id} {
to { opacity: 0; }
}
[data-bc-content="${id}"][data-state="open"] {
animation: bc-in-${id} 150ms var(--ease-out);
}
[data-bc-content="${id}"][data-state="closed"] {
animation: bc-out-${id} 100ms var(--ease-exit) forwards;
}
@media (prefers-reduced-motion: reduce) {
[data-bc-content="${id}"] { animation-duration: 1ms !important; }
}
`}</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-colors duration-150 ease-out after:absolute after:inset-x-0 after:-inset-y-2 hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
>
<Ellipsis className="size-3.5" />
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-bc-content={id}
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>
);
}