Breadcrumbs
Navigation

Breadcrumbs

Breadcrumb trail that collapses deep paths into a dropdown, with underlines that slide in via background-size.

Install

npx shadcn@latest add @paragon/breadcrumbs

breadcrumbs.tsx

"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>
  );
}