Installation
Or, with @layro in your components.json:
Code
"use client";
/**
* Breadcrumb — custom (Phase 5). Reduced-motion gated expand/dropdown motion.
* Dropdown overflow uses overlay + menu-item tokens. RTL: prefer IconChevronLeft
* when `dir="rtl"` is set on an ancestor (consumers should flip chevrons).
*/
import * as React from "react";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import {
IconChevronRight,
IconDots,
IconSlash,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
/** Motion tiers, the same speeds as the --primitive-motion-* tokens. */
const SPRING = {
fast: { type: "spring", duration: 0.08, bounce: 0 },
moderate: { type: "spring", duration: 0.16, bounce: 0 },
slow: { type: "spring", duration: 0.24, bounce: 0 },
} as const;
/* ─── Types ─────────────────────────────────────────────────── */
export type BreadcrumbVariant =
| "default"
| "with-icons"
| "collapsed"
| "dropdown"
| "custom-separator";
export interface BreadcrumbProps extends React.HTMLAttributes<HTMLElement> {
variant?: BreadcrumbVariant;
/** Custom separator node — used by all variants but most visible in `custom-separator`. */
separator?: React.ReactNode;
/** For collapsed/dropdown variants: maximum visible items before collapsing (default 3). */
maxItems?: number;
}
export interface BreadcrumbItemProps
extends React.HTMLAttributes<HTMLLIElement> {
/** Link href — omit for the current (last) page. */
href?: string;
/** Optional leading icon. */
icon?: React.ReactNode;
/** Mark as the current page — renders as static text. */
isCurrentPage?: boolean;
}
export interface BreadcrumbSeparatorProps
extends React.HTMLAttributes<HTMLLIElement> {
children?: React.ReactNode;
}
/* ─── Context ───────────────────────────────────────────────── */
type BreadcrumbContextValue = {
variant: BreadcrumbVariant;
separator: React.ReactNode;
};
const BreadcrumbContext = React.createContext<BreadcrumbContextValue>({
variant: "default",
separator: null,
});
/* ─── Easing ────────────────────────────────────────────────── */
/* ─── Default separators per variant ────────────────────────── */
function getDefaultSeparator(variant: BreadcrumbVariant): React.ReactNode {
switch (variant) {
case "with-icons":
return <IconChevronRight className="size-3.5 text-[var(--muted-foreground)] opacity-50" />;
case "custom-separator":
return (
<span className="text-[var(--muted-foreground)] opacity-40 text-xs select-none">
→
</span>
);
default:
return <IconSlash className="size-3 text-[var(--muted-foreground)] opacity-40 -rotate-[20deg]" />;
}
}
/* ─── <Breadcrumb> ──────────────────────────────────────────── */
export const Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(
(
{
variant = "default",
separator: customSeparator,
maxItems = 3,
className,
children,
...props
},
ref,
) => {
const separator = customSeparator ?? getDefaultSeparator(variant);
const ctx = React.useMemo<BreadcrumbContextValue>(
() => ({ variant, separator }),
[variant, separator],
);
const items = React.Children.toArray(children);
const maxVisibleItems = Number.isFinite(maxItems)
? Math.max(3, Math.floor(maxItems))
: 3;
/* ── Collapsed / Dropdown logic ──────────────────────────── */
if (
(variant === "collapsed" || variant === "dropdown") &&
items.length > maxVisibleItems
) {
const visibleMiddleCount = maxVisibleItems - 3;
const leadingMiddleCount = Math.floor(visibleMiddleCount / 2);
const trailingMiddleCount = visibleMiddleCount - leadingMiddleCount;
const hiddenStartIndex = 1 + leadingMiddleCount;
const hiddenEndIndex = items.length - 1 - trailingMiddleCount;
const leadingItems = items.slice(0, hiddenStartIndex);
const hiddenItems = items.slice(hiddenStartIndex, hiddenEndIndex);
const trailingItems = items.slice(hiddenEndIndex);
return (
<BreadcrumbContext.Provider value={ctx}>
<nav data-wensity-primitive="" ref={ref} aria-label="breadcrumb" className={className} {...props}>
<ol className="flex items-center gap-1.5">
{leadingItems.map((child, i) => (
<React.Fragment key={`leading-${i}`}>
{i > 0 && <BreadcrumbSeparatorInternal separator={separator} />}
<BreadcrumbItemWrapper>{child}</BreadcrumbItemWrapper>
</React.Fragment>
))}
<BreadcrumbSeparatorInternal separator={separator} />
{/* Collapsed middle */}
<CollapsedMiddle
variant={variant}
items={hiddenItems}
separator={separator}
/>
<BreadcrumbSeparatorInternal separator={separator} />
{trailingItems.map((child, i) => (
<React.Fragment key={`trailing-${i}`}>
{i > 0 && <BreadcrumbSeparatorInternal separator={separator} />}
<BreadcrumbItemWrapper>{child}</BreadcrumbItemWrapper>
</React.Fragment>
))}
</ol>
</nav>
</BreadcrumbContext.Provider>
);
}
/* ── Standard rendering ──────────────────────────────────── */
return (
<BreadcrumbContext.Provider value={ctx}>
<nav data-wensity-primitive="" ref={ref} aria-label="breadcrumb" className={className} {...props}>
<ol className="flex flex-wrap items-center gap-1.5">
{items.map((child, i) => (
<React.Fragment key={i}>
{i > 0 && <BreadcrumbSeparatorInternal separator={separator} />}
<BreadcrumbItemWrapper>{child}</BreadcrumbItemWrapper>
</React.Fragment>
))}
</ol>
</nav>
</BreadcrumbContext.Provider>
);
},
);
Breadcrumb.displayName = "Breadcrumb";
/* ─── Internal separator ────────────────────────────────────── */
function BreadcrumbSeparatorInternal({
separator,
}: {
separator: React.ReactNode;
}) {
return (
<li
role="presentation"
aria-hidden
className="flex items-center select-none [&_svg]:size-3"
>
{separator}
</li>
);
}
/* ─── Item wrapper (thin pass-through for ol semantics) ────── */
function BreadcrumbItemWrapper({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
/* ─── <BreadcrumbItem> ──────────────────────────────────────── */
const linkBase = cn(
"inline-flex items-center gap-1.5 text-sm font-medium leading-none tracking-[-0.01em]",
"transition-colors duration-[var(--primitive-motion-fast)] ease-[var(--primitive-ease,cubic-bezier(0.23,1,0.32,1))]",
"motion-reduce:transition-none",
"outline-none select-none",
"focus-visible:ring-2 focus-visible:ring-[var(--primitive-ring)] focus-visible:rounded-[var(--primitive-radius-control-sm)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-3.5",
);
const ghostIconButtonClass = cn(
"inline-flex size-7 items-center justify-center rounded-[var(--primitive-radius-item)]",
"text-[var(--muted-foreground)]",
"transition-colors duration-[var(--primitive-motion-fast)] ease-[var(--primitive-ease,cubic-bezier(0.23,1,0.32,1))]",
"motion-reduce:transition-none",
"hover:text-[var(--foreground)] hover:bg-[var(--primitive-surface-hover)]",
"focus-visible:ring-2 focus-visible:ring-[var(--primitive-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
"outline-none",
);
export const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
BreadcrumbItemProps
>(({ href, icon, isCurrentPage, className, children, ...props }, ref) => {
const { variant } = React.useContext(BreadcrumbContext);
const content = (
<BreadcrumbItemContent
href={href}
icon={icon}
isCurrentPage={isCurrentPage}
className={className}
variant={variant}
>
{children}
</BreadcrumbItemContent>
);
return (
<li ref={ref} className="flex items-center" {...props}>
{content}
</li>
);
});
BreadcrumbItem.displayName = "BreadcrumbItem";
function BreadcrumbItemContent({
href,
icon,
isCurrentPage,
className,
children,
variant,
}: BreadcrumbItemProps & { variant: BreadcrumbVariant }) {
const showIcon = variant === "with-icons" && icon;
if (isCurrentPage || !href) {
return (
<span
aria-current={isCurrentPage ? "page" : undefined}
className={cn(
linkBase,
"text-[var(--foreground)] cursor-default",
className,
)}
>
{showIcon && icon}
{children}
</span>
);
}
return (
<a
href={href}
onClick={(e) => {
if (href === "#") {
e.preventDefault();
}
}}
className={cn(
linkBase,
"text-[var(--muted-foreground)]",
"hover:text-[var(--foreground)]",
"group/crumb relative",
className,
)}
>
{showIcon && icon}
<span className="relative">
{children}
{/* Underline slide-in from left on hover */}
<span
className={cn(
"absolute -bottom-0.5 left-0 h-px w-full origin-left scale-x-0",
"bg-[var(--foreground)] opacity-40",
"transition-transform duration-[var(--primitive-motion-moderate)] ease-[cubic-bezier(0.23,1,0.32,1)]",
"group-hover/crumb:scale-x-100",
)}
/>
</span>
</a>
);
}
/* ─── <BreadcrumbSeparator> (public, for custom-separator) ─── */
export const BreadcrumbSeparator = React.forwardRef<
HTMLLIElement,
BreadcrumbSeparatorProps
>(({ className, children, ...props }, ref) => {
const { separator } = React.useContext(BreadcrumbContext);
return (
<li
ref={ref}
role="presentation"
aria-hidden
className={cn("flex items-center select-none [&_svg]:size-3", className)}
{...props}
>
{children ?? separator}
</li>
);
});
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
/* ─── Collapsed middle items ────────────────────────────────── */
function CollapsedMiddle({
variant,
items,
separator,
}: {
variant: BreadcrumbVariant;
items: React.ReactNode[];
separator: React.ReactNode;
}) {
const reduceMotion = useReducedMotion();
const [expanded, setExpanded] = React.useState(false);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLLIElement>(null);
/* Close dropdown on outside click */
React.useEffect(() => {
if (!dropdownOpen) return;
function handleClick(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [dropdownOpen]);
/* ── Collapsed variant: expand inline ───── */
if (variant === "collapsed") {
if (expanded) {
return (
<>
{items.map((child, i) => (
<React.Fragment key={i}>
<AnimatedBreadcrumbItem child={child} index={i} />
{i < items.length - 1 && (
<motion.li
role="presentation"
aria-hidden
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { ...SPRING.fast, delay: i * 0.04 + 0.05 }
}
className="flex items-center select-none [&_svg]:size-3"
>
{separator}
</motion.li>
)}
</React.Fragment>
))}
</>
);
}
return (
<li className="flex items-center">
<button
type="button"
aria-label={`Show ${items.length} more items`}
onClick={() => setExpanded(true)}
className={ghostIconButtonClass}
>
<IconDots className="size-3.5" />
</button>
</li>
);
}
/* ── Dropdown variant: floating menu ───── */
return (
<li className="relative flex items-center" ref={dropdownRef}>
<button
type="button"
aria-label={`Show ${items.length} more items`}
aria-expanded={dropdownOpen}
onClick={() => setDropdownOpen((v) => !v)}
className={cn(
ghostIconButtonClass,
dropdownOpen &&
"text-[var(--foreground)] bg-[var(--primitive-surface-selected)]",
)}
>
<IconDots className="size-3.5" />
</button>
<AnimatePresence>
{dropdownOpen && (
<motion.div
data-surface="overlay"
initial={reduceMotion ? false : { opacity: 0, scale: 0.96, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduceMotion ? undefined : { opacity: 0, scale: 0.96, y: 4 }}
transition={
reduceMotion
? { duration: 0 }
: SPRING.fast
}
className={cn(
"absolute bottom-full left-1/2 z-[var(--primitive-z-popover)] mb-2 -translate-x-1/2",
"min-w-[140px] rounded-[var(--primitive-radius-popup)] p-1",
)}
style={{ transformOrigin: "bottom center" }}
>
{items.map((child, i) => {
/* Extract text/href from BreadcrumbItem children */
const childEl = child as React.ReactElement<BreadcrumbItemProps>;
const href = childEl?.props?.href;
const label = childEl?.props?.children;
const icon = childEl?.props?.icon;
return (
<a
key={i}
href={href ?? "#"}
onClick={(e) => {
setDropdownOpen(false);
if (!href || href === "#") {
e.preventDefault();
}
}}
className={cn(
"flex items-center gap-2.5 rounded-[var(--primitive-radius-item)] px-2.5 py-2 text-sm font-medium",
"text-[var(--muted-foreground)]",
"transition-colors duration-[var(--primitive-motion-fast)] ease-[var(--primitive-ease,cubic-bezier(0.23,1,0.32,1))]",
"motion-reduce:transition-none",
"hover:text-[var(--foreground)] hover:bg-[var(--primitive-surface-selected)]",
"outline-none",
"[&_svg]:size-3.5 [&_svg]:shrink-0",
)}
>
{icon}
{label}
</a>
);
})}
</motion.div>
)}
</AnimatePresence>
</li>
);
}
function AnimatedBreadcrumbItem({
child,
index,
}: {
child: React.ReactNode;
index: number;
}) {
const { variant } = React.useContext(BreadcrumbContext);
const reduceMotion = useReducedMotion();
const transition = reduceMotion
? { duration: 0 }
: { ...SPRING.fast, delay: index * 0.04 };
if (!React.isValidElement<BreadcrumbItemProps>(child)) {
return (
<li className="flex items-center">
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={transition}
className="flex items-center"
>
{child}
</motion.div>
</li>
);
}
const { href, icon, isCurrentPage, className, children, ...props } =
child.props;
return (
<li {...props} className="flex items-center">
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={transition}
className="flex items-center"
>
<BreadcrumbItemContent
href={href}
icon={icon}
isCurrentPage={isCurrentPage}
className={className}
variant={variant}
>
{children}
</BreadcrumbItemContent>
</motion.div>
</li>
);
}Versions
1.0.0 · current
Oct 27, 2026
First release.