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

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