"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface BreadcrumbItem {
/** Stable key; defaults to href or label. */
id?: string;
label: string;
href?: string;
/** Optional leading icon (decorative). */
icon?: React.ReactNode;
}
export interface SmartBreadcrumbsProps extends Omit<React.HTMLAttributes<HTMLElement>, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"> {
/** Trail from the root to the current page. The last item is the current page. */
items: BreadcrumbItem[];
/** Items at the start that never collapse. */
keepFirst?: number;
/** Items at the end (including the current page) that never collapse. */
keepLast?: number;
separator?: "chevron" | "slash" | "dot";
size?: "sm" | "md";
/** Spring for the collapse / expand layout animation. */
spring?: Spring;
/** Accent for hover underlines, focus rings and the open overflow button. */
accent?: string;
/** Accessible name of the nav landmark. */
label?: string;
/** Accessible name of the overflow ("…") button. */
overflowLabel?: string;
/** Called when a crumb or overflow menu item is activated (before navigation). */
onNavigate?: (item: BreadcrumbItem, event: React.MouseEvent) => void;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const keyOf = (item: BreadcrumbItem, index: number) => item.id ?? item.href ?? `${index}-${item.label}`;
function Separator({ kind, className = "" }: { kind: "chevron" | "slash" | "dot"; className?: string }) {
return (
<span aria-hidden="true" className={`flex shrink-0 items-center text-zinc-300 dark:text-zinc-600 ${className}`}>
{kind === "chevron" ? (
<svg viewBox="0 0 16 16" fill="none" className="size-[1em]">
<path d="M6 3.5 10.5 8 6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : kind === "slash" ? (
<span className="font-light">/</span>
) : (
<span className="font-bold">·</span>
)}
</span>
);
}
/**
* Breadcrumbs that measure themselves with a ResizeObserver and fold middle items into an
* accessible "…" menu button when space runs short, keeping the first and last items visible.
* Collapsing and expanding animate with a layout spring; the current page carries aria-current.
*/
export function SmartBreadcrumbs({
items,
keepFirst = 1,
keepLast = 2,
separator = "chevron",
size = "md",
spring = { stiffness: 420, damping: 36, mass: 0.8 },
accent = "#FF7448",
label = "Breadcrumb",
overflowLabel = "Show hidden pages",
onNavigate,
className = "",
style,
...props
}: SmartBreadcrumbsProps) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const navRef = React.useRef<HTMLElement>(null);
const measureRef = React.useRef<HTMLOListElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const menuRef = React.useRef<HTMLUListElement>(null);
const focusOnOpen = React.useRef<"first" | "last">("first");
const [layout, setLayout] = React.useState({ hidden: 0, ready: false, tight: false });
const [menuOpen, setMenuOpen] = React.useState(false);
const first = Math.max(0, Math.min(keepFirst, items.length));
const last = Math.max(1, Math.min(keepLast, items.length - first));
const collapsible = Math.max(0, items.length - first - last);
/** Finds how many middle items must fold away for the trail to fit on one line. */
const measure = React.useEffectEvent(() => {
const nav = navRef.current;
const ruler = measureRef.current;
if (!nav || !ruler) return;
const available = nav.clientWidth - 1;
const widthOf = (name: string) => (ruler.querySelector<HTMLElement>(`[data-measure="${name}"]`)?.offsetWidth ?? 0);
const sep = widthOf("sep");
const ellipsis = widthOf("ellipsis");
const widths = items.map((_, i) => widthOf(String(i)));
const total = (hide: number) => {
let sum = 0;
for (let i = 0; i < widths.length; i++) if (i < first || i >= first + hide) sum += widths[i];
const lis = widths.length - hide + (hide > 0 ? 1 : 0);
return sum + (hide > 0 ? ellipsis : 0) + sep * Math.max(0, lis - 1);
};
let hidden = 0;
while (hidden < collapsible && total(hidden) > available) hidden++;
// A single folded item would trade one crumb for "…": fold at least two when possible.
if (hidden === 1 && collapsible >= 2) hidden = 2;
// Still too wide with everything folded: ancestors shrink to their icon (or a short label).
const tight = total(hidden) > available;
setLayout((prev) => (prev.hidden === hidden && prev.tight === tight && prev.ready ? prev : { hidden, tight, ready: true }));
});
React.useEffect(() => {
const nav = navRef.current;
const ruler = measureRef.current;
if (!nav || !ruler) return;
const observer = new ResizeObserver(() => measure());
observer.observe(nav);
observer.observe(ruler);
return () => observer.disconnect();
}, []);
// Re-measure when the trail or its rules change (the ruler's size may stay the same).
const signature = `${items.map((item) => item.label).join("\u0000")}|${first}|${last}|${separator}|${size}`;
React.useEffect(() => {
const frame = requestAnimationFrame(() => measure());
return () => cancelAnimationFrame(frame);
}, [signature]);
const hidden = Math.min(layout.hidden, collapsible);
const hiddenItems = items.slice(first, first + hidden);
// Close the menu when nothing is folded any more (adjust state during render).
if (menuOpen && hidden === 0) setMenuOpen(false);
// While the menu is open: focus an item, close on outside pointer or focus leaving.
React.useEffect(() => {
if (!menuOpen) return;
const menuItems = Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
(focusOnOpen.current === "last" ? menuItems[menuItems.length - 1] : menuItems[0])?.focus();
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (menuRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
setMenuOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [menuOpen]);
const openMenu = (focus: "first" | "last") => {
focusOnOpen.current = focus;
setMenuOpen(true);
};
const closeMenu = (returnFocus: boolean) => {
setMenuOpen(false);
if (returnFocus) buttonRef.current?.focus();
};
const onMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
const menuItems = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]'));
const index = menuItems.indexOf(document.activeElement as HTMLElement);
const go = (i: number) => menuItems[(i + menuItems.length) % menuItems.length]?.focus();
if (event.key === "ArrowDown") go(index + 1);
else if (event.key === "ArrowUp") go(index - 1);
else if (event.key === "Home") go(0);
else if (event.key === "End") go(menuItems.length - 1);
else if (event.key === "Escape") closeMenu(true);
else if (event.key === "Tab") {
setMenuOpen(false);
return;
} else return;
event.preventDefault();
};
const text = size === "sm" ? "text-[13px]" : "text-[15px]";
const sepPad = size === "sm" ? "px-1.5" : "px-2";
const iconBox = size === "sm" ? "[&_svg]:size-3.5" : "[&_svg]:size-4";
const crumbBase = `inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap ${iconBox}`;
const linkClass = `${crumbBase} rounded-[3px] text-zinc-500 underline-offset-4 decoration-[var(--sb-accent)] transition-colors outline-hidden hover:text-zinc-900 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sb-accent)] dark:text-zinc-400 dark:hover:text-zinc-100`;
const transition = layout.ready && !reduceMotion ? { type: "spring" as const, ...spring } : { duration: 0 };
const crumbContent = (item: BreadcrumbItem, current: boolean, tight = false) => (
<>
{item.icon ? (
<span aria-hidden="true" className="flex shrink-0 items-center">
{item.icon}
</span>
) : null}
<span className={current ? "truncate" : tight ? (item.icon ? "sr-only" : "max-w-[4rem] truncate") : "max-w-[12rem] truncate"}>{item.label}</span>
</>
);
const renderCrumb = (item: BreadcrumbItem, index: number) => {
const current = index === items.length - 1;
if (current) {
return (
<span aria-current="page" className={`${crumbBase} font-medium text-zinc-900 dark:text-zinc-50`}>
{crumbContent(item, true)}
</span>
);
}
if (item.href) {
return (
<a href={item.href} title={layout.tight ? item.label : undefined} className={linkClass} onClick={(event) => onNavigate?.(item, event)}>
{crumbContent(item, false, layout.tight)}
</a>
);
}
return (
<span title={layout.tight ? item.label : undefined} className={`${crumbBase} text-zinc-500 dark:text-zinc-400`}>
{crumbContent(item, false, layout.tight)}
</span>
);
};
// Visible list: first items, the overflow crumb, then the rest. Only the current page truncates.
type Entry = { key: string; node: React.ReactNode; current?: boolean };
const entries: Entry[] = [];
items.forEach((item, index) => {
if (index === first && hidden > 0) entries.push({ key: "__overflow", node: null });
if (index >= first && index < first + hidden) return;
entries.push({ key: keyOf(item, index), node: renderCrumb(item, index), current: index === items.length - 1 });
});
const menuId = `${uid}-menu`;
const buttonId = `${uid}-more`;
return (
<nav
ref={navRef}
aria-label={label}
className={`relative w-full min-w-0 ${text} ${className}`}
style={{ ["--sb-accent" as string]: accent, ...style }}
{...props}
>
<ol className="relative flex min-w-0 flex-nowrap items-center">
<AnimatePresence initial={false} mode="popLayout">
{entries.map((entry, i) => (
<motion.li
key={entry.key}
layout="position"
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -6, transition: { duration: layout.ready && !reduceMotion ? 0.14 : 0 } }}
transition={transition}
className={`flex items-center ${entry.key === "__overflow" ? "relative shrink-0" : entry.current ? "min-w-0" : "shrink-0"}`}
>
{i > 0 ? <Separator kind={separator} className={sepPad} /> : null}
{entry.key === "__overflow" ? (
<>
<button
ref={buttonRef}
id={buttonId}
type="button"
aria-label={`${overflowLabel} (${hidden})`}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-controls={menuOpen ? menuId : undefined}
onClick={() => (menuOpen ? closeMenu(false) : openMenu("first"))}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
openMenu(event.key === "ArrowUp" ? "last" : "first");
}
}}
className={`grid h-6 min-w-7 place-items-center rounded-md px-1.5 tracking-widest text-zinc-500 transition-colors outline-hidden hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--sb-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100 ${menuOpen ? "bg-zinc-900/[0.06] text-zinc-900 dark:bg-white/[0.08] dark:text-zinc-100" : ""}`}
>
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4">
<circle cx="3.5" cy="8" r="1.25" fill="currentColor" />
<circle cx="8" cy="8" r="1.25" fill="currentColor" />
<circle cx="12.5" cy="8" r="1.25" fill="currentColor" />
</svg>
</button>
<AnimatePresence>
{menuOpen ? (
<motion.ul
ref={menuRef}
id={menuId}
role="menu"
aria-labelledby={buttonId}
onKeyDown={onMenuKeyDown}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -4 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -2, transition: { duration: 0.12 } }}
transition={reduceMotion ? { duration: 0.01 } : { type: "spring", stiffness: 520, damping: 34 }}
style={{ transformOrigin: "16px 0px" }}
className={`absolute left-0 top-full z-30 mt-2 min-w-48 max-w-72 rounded-xl border border-zinc-900/10 bg-white p-1 shadow-[0_18px_40px_-18px_rgb(0_0_0/0.35)] dark:border-white/10 dark:bg-[#1a1d21] ${size === "sm" ? "-ml-1.5" : "-ml-2"}`}
>
{hiddenItems.map((item, i) => {
const itemClass = `flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-zinc-700 outline-hidden ${iconBox} hover:bg-zinc-900/[0.05] focus:bg-zinc-900/[0.06] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-solid focus-visible:outline-[var(--sb-accent)] dark:text-zinc-300 dark:hover:bg-white/[0.06] dark:focus:bg-white/[0.08]`;
const inner = (
<>
<span aria-hidden="true" className="w-4 shrink-0 font-mono text-[10px] tabular-nums text-zinc-400 dark:text-zinc-500">
{first + i + 1}
</span>
{item.icon ? (
<span aria-hidden="true" className="flex shrink-0 items-center text-zinc-400 dark:text-zinc-500">
{item.icon}
</span>
) : null}
<span className="truncate">{item.label}</span>
</>
);
return (
<li key={keyOf(item, first + i)} role="none">
{item.href ? (
<a
role="menuitem"
tabIndex={-1}
href={item.href}
className={itemClass}
onClick={(event) => {
onNavigate?.(item, event);
setMenuOpen(false);
}}
>
{inner}
</a>
) : (
<button
role="menuitem"
tabIndex={-1}
type="button"
className={itemClass}
onClick={(event) => {
onNavigate?.(item, event);
closeMenu(true);
}}
>
{inner}
</button>
)}
</li>
);
})}
</motion.ul>
) : null}
</AnimatePresence>
</>
) : (
entry.node
)}
</motion.li>
))}
</AnimatePresence>
</ol>
{/* Invisible ruler: every crumb at its natural width, plus a separator and the overflow button. */}
<div aria-hidden="true" className="pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden">
<ol ref={measureRef} className="flex w-max items-center whitespace-nowrap">
<li data-measure="sep" className="flex">
<Separator kind={separator} className={sepPad} />
</li>
<li data-measure="ellipsis" className="flex">
<span className="grid h-6 min-w-7 px-1.5" />
</li>
{items.map((item, index) => (
<li key={keyOf(item, index)} data-measure={index} className="flex">
<span className={`${crumbBase} ${index === items.length - 1 ? "font-medium" : ""}`}>{crumbContent(item, index === items.length - 1)}</span>
</li>
))}
</ol>
</div>
</nav>
);
}