"use client";
import { ChevronRight, Ellipsis } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
} from "motion/react";
import {
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type ComponentPropsWithoutRef,
type KeyboardEvent,
type ReactElement,
type ReactNode,
} from "react";
import { EASE_OUT_CSS, SPRING_LAYOUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------------ */
/* Composable primitives */
/* ------------------------------------------------------------------------ */
export type BreadcrumbDataItem = {
label: ReactNode;
href?: string;
icon?: ReactNode;
/** Render the link through your router, e.g. `(p) => `. */
render?: (props: BreadcrumbLinkRenderProps) => ReactElement;
};
type BreadcrumbNavProps = ComponentPropsWithoutRef<"nav">;
export type BreadcrumbProps = BreadcrumbNavProps & {
/** Data-driven mode. Omit to compose `BreadcrumbList` children yourself. */
items?: BreadcrumbDataItem[];
/** Collapse middle items into "…" when there are more than this many. */
maxItems?: number;
/** "expand" reveals hidden items inline; "menu" lists them in a dropdown. */
collapse?: "expand" | "menu";
separator?: ReactNode;
};
export function Breadcrumb({
items,
maxItems = 4,
collapse = "expand",
separator,
className,
children,
...props
}: BreadcrumbProps) {
return (
{items ? (
) : (
children
)}
);
}
export function BreadcrumbList({ className, ...props }: ComponentPropsWithoutRef<"ol">) {
return (
);
}
export function BreadcrumbItem({ className, ...props }: ComponentPropsWithoutRef<"li">) {
return ;
}
export type BreadcrumbLinkRenderProps = {
href: string;
className: string;
children: ReactNode;
};
export type BreadcrumbLinkProps = Omit, "href" | "children"> & {
href: string;
children: ReactNode;
/** Swap the `` for a router link: `render={(p) => }`. */
render?: (props: BreadcrumbLinkRenderProps) => ReactElement;
};
const linkClass =
"group/crumb relative inline-flex min-w-0 items-center gap-1.5 rounded-sm text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
function LinkContent({ children }: { children: ReactNode }) {
return (
<>
{children}
{/* Underline slides in from the left and leaves to the right: the origin
flips on hover, so scaleX grows and shrinks in the travel direction. */}
>
);
}
export function BreadcrumbLink({
href,
render,
className,
children,
...props
}: BreadcrumbLinkProps) {
const merged = cn(linkClass, className);
if (render) {
return render({ href, className: merged, children: {children} });
}
return (
{children}
);
}
export function BreadcrumbPage({ className, ...props }: ComponentPropsWithoutRef<"span">) {
return (
);
}
export function BreadcrumbSeparator({
className,
children,
...props
}: ComponentPropsWithoutRef<"li">) {
return (
svg]:size-3.5", className)}
{...props}
>
{children ?? }
);
}
export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"button">) {
return (
);
}
/* ------------------------------------------------------------------------ */
/* Data-driven trail */
/* ------------------------------------------------------------------------ */
function CrumbBody({ item, current }: { item: BreadcrumbDataItem; current: boolean }) {
const content = (
<>
{item.icon ? (
{item.icon}
) : null}
{item.label}
>
);
if (current || !item.href) return {content} ;
return (
{content}
);
}
type Entry =
| { kind: "item"; item: BreadcrumbDataItem; index: number }
| { kind: "ellipsis"; hidden: { item: BreadcrumbDataItem; index: number }[] };
function BreadcrumbTrail({
items,
maxItems,
collapse,
separator,
}: {
items: BreadcrumbDataItem[];
maxItems: number;
collapse: "expand" | "menu";
separator?: ReactNode;
}) {
const reduce = useReducedMotion();
const [expanded, setExpanded] = useState(false);
const firstRevealed = useRef(null);
const focusRevealed = useRef(false);
const indexed = items.map((item, index) => ({ item, index }));
const shouldCollapse = !expanded && items.length > Math.max(maxItems, 2);
const tail = Math.max(1, maxItems - 2);
const hidden = shouldCollapse ? indexed.slice(1, items.length - tail) : [];
const entries: Entry[] = shouldCollapse
? [
{ kind: "item", ...indexed[0] },
{ kind: "ellipsis", hidden },
...indexed.slice(items.length - tail).map((e) => ({ kind: "item" as const, ...e })),
]
: indexed.map((e) => ({ kind: "item" as const, ...e }));
// The "…" button unmounts on expand; hand focus to the first revealed link
// so keyboard users are not dropped back on .
useEffect(() => {
if (expanded && focusRevealed.current) {
focusRevealed.current = false;
firstRevealed.current?.querySelector("a,[tabindex]")?.focus();
}
}, [expanded]);
const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;
return (
{/* relative: popLayout takes exiting crumbs out of flow, absolutely. */}
{entries.flatMap((entry, i) => {
const key = entry.kind === "item" ? `item-${entry.index}` : "ellipsis";
const last = entry.kind === "item" && entry.index === items.length - 1;
const isFirstRevealed = expanded && entry.kind === "item" && entry.index === 1;
// Flat keyed s (no Fragment) so AnimatePresence tracks each
// separator and crumb on its own and plays their exits.
const nodes: ReactElement[] = [];
if (i > 0) {
nodes.push(
{separator ?? }
,
);
}
nodes.push(
{entry.kind === "item" ? (
) : collapse === "menu" ? (
) : (
{
focusRevealed.current = true;
setExpanded(true);
}}
/>
)}
,
);
return nodes;
})}
);
}
function CollapsedMenu({ hidden }: { hidden: { item: BreadcrumbDataItem; index: number }[] }) {
const reduce = useReducedMotion();
const [open, setOpen] = useState(false);
const menuId = useId();
const rootRef = useRef(null);
const triggerRef = useRef(null);
const listRef = useRef(null);
useEffect(() => {
if (!open) return;
const onPointer = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onPointer);
listRef.current?.querySelector("a")?.focus();
return () => document.removeEventListener("pointerdown", onPointer);
}, [open]);
const close = (refocus: boolean) => {
setOpen(false);
if (refocus) triggerRef.current?.focus();
};
const onKeyDown = (event: KeyboardEvent) => {
if (!open) return;
const links = Array.from(listRef.current?.querySelectorAll("a") ?? []);
const at = links.indexOf(document.activeElement as HTMLElement);
if (event.key === "Escape") {
event.preventDefault();
close(true);
} else if (event.key === "ArrowDown") {
event.preventDefault();
links[(at + 1) % links.length]?.focus();
} else if (event.key === "ArrowUp") {
event.preventDefault();
links[(at - 1 + links.length) % links.length]?.focus();
} else if (event.key === "Tab") {
setOpen(false);
}
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: delegates Escape/arrow keys for the popup's links
setOpen((o) => !o)}
className={cn(open && "bg-muted text-foreground")}
/>
{open ? (
) : null}
);
}
function defaultMenuLink({ href, className, children }: BreadcrumbLinkRenderProps) {
return (
{children}
);
}