A breadcrumb trail with chevron, slash, dot or arrow separators, an accent underline that sweeps in on hover, and long trails whose middle pages collapse into an animated ellipsis dropdown.
Preview
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add breadcrumbWrites components/ui/breadcrumb.tsx and components/styles/breadcrumb.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Breadcrumb } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Breadcrumb />6 );7}Props
Accessibility
Rendered as <nav aria-label> containing an ordered list; separators are aria-hidden presentation items. The last item carries aria-current="page". The ellipsis is a labelled button ('Show N more') with aria-haspopup=menu and aria-expanded; its menu uses role=menu/menuitem, focuses the first item on open, supports ArrowUp/ArrowDown/Home/End, closes on Escape (returning focus to the button), Tab and outside click. Hover/focus underline is decorative; reduced motion removes transitions.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/breadcrumb.module.css";6 7export interface BreadcrumbItem {8 /** Visible label */9 label: string;10 /** Link target (omit for the current page) */11 href?: string;12}13 14export interface BreadcrumbProps {15 /** Trail of pages from root to the current page */16 items?: BreadcrumbItem[];17 /** Separator glyph between items */18 separator?: "chevron" | "slash" | "dot" | "arrow";19 /** Collapse middle items into an ellipsis menu when the trail is longer than this */20 maxItems?: number;21 /** Enable collapsing of middle items */22 collapsible?: boolean;23 /** Show a home icon before the first label */24 showHomeIcon?: boolean;25 /** Size preset */26 size?: "sm" | "md" | "lg";27 /** Accent color for the hover underline and focus highlights */28 accentColor?: string;29 /** Accessible label of the nav landmark */30 ariaLabel?: string;31 /** Called when a link is activated; links with href "#" are not followed */32 onNavigate?: (item: BreadcrumbItem, index: number) => void;33 /** Optional className merged onto the root */34 className?: string;35 /** Optional inline styles merged onto the root */36 style?: React.CSSProperties;37}38 39const DEFAULT_ITEMS: BreadcrumbItem[] = [40 { label: "Home", href: "#" },41 { label: "Workspace", href: "#" },42 { label: "Projects", href: "#" },43 { label: "MotionFoundry", href: "#" },44 { label: "Components", href: "#" },45 { label: "Breadcrumb" },46];47 48function Separator({ kind }: { kind: NonNullable<BreadcrumbProps["separator"]> }) {49 return (50 <li role="presentation" aria-hidden="true" className={styles.sep}>51 {kind === "slash" ? (52 "/"53 ) : kind === "dot" ? (54 <span className={styles.dot} />55 ) : (56 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">57 <path d={kind === "arrow" ? "M5 12h14M13 6l6 6-6 6" : "m9 6 6 6-6 6"} />58 </svg>59 )}60 </li>61 );62}63 64export function Breadcrumb({65 items = DEFAULT_ITEMS,66 separator = "chevron",67 maxItems = 4,68 collapsible = true,69 showHomeIcon = true,70 size = "md",71 accentColor = "#6366f1",72 ariaLabel = "Breadcrumb",73 onNavigate,74 className = "",75 style,76}: BreadcrumbProps) {77 const reduce = useReducedMotion();78 const [open, setOpen] = React.useState(false);79 const menuId = React.useId();80 const wrapRef = React.useRef<HTMLLIElement>(null);81 const triggerRef = React.useRef<HTMLButtonElement>(null);82 const itemRefs = React.useRef<Array<HTMLAnchorElement | null>>([]);83 84 const max = Math.max(2, Math.round(maxItems));85 const collapse = collapsible && items.length > max;86 const tailCount = Math.max(1, max - 1);87 const hidden = collapse ? items.slice(1, items.length - tailCount) : [];88 const visible = collapse89 ? [{ item: items[0], index: 0 }, ...items.slice(items.length - tailCount).map((item, i) => ({ item, index: items.length - tailCount + i }))]90 : items.map((item, index) => ({ item, index }));91 92 React.useEffect(() => {93 if (!open) return;94 const raf = requestAnimationFrame(() => itemRefs.current[0]?.focus());95 const onDown = (e: PointerEvent) => {96 if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);97 };98 document.addEventListener("pointerdown", onDown);99 return () => {100 cancelAnimationFrame(raf);101 document.removeEventListener("pointerdown", onDown);102 };103 }, [open]);104 105 const close = (refocus: boolean) => {106 setOpen(false);107 if (refocus) triggerRef.current?.focus();108 };109 110 const onMenuKey = (e: React.KeyboardEvent<HTMLUListElement>) => {111 const nodes = itemRefs.current.filter((n): n is HTMLAnchorElement => !!n);112 const i = nodes.findIndex((n) => n === document.activeElement);113 let next = -1;114 if (e.key === "ArrowDown") next = (i + 1) % nodes.length;115 else if (e.key === "ArrowUp") next = (i - 1 + nodes.length) % nodes.length;116 else if (e.key === "Home") next = 0;117 else if (e.key === "End") next = nodes.length - 1;118 else if (e.key === "Escape") {119 e.preventDefault();120 close(true);121 return;122 } else if (e.key === "Tab") close(false);123 if (next < 0) return;124 e.preventDefault();125 nodes[next]?.focus();126 };127 128 const activate = (e: React.MouseEvent<HTMLAnchorElement>, item: BreadcrumbItem, index: number) => {129 if (!item.href || item.href === "#") e.preventDefault();130 onNavigate?.(item, index);131 };132 133 const renderLink = (item: BreadcrumbItem, index: number) => {134 const isLast = index === items.length - 1;135 const icon =136 showHomeIcon && index === 0 ? (137 <svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">138 <path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />139 </svg>140 ) : null;141 if (isLast || item.href === undefined) {142 return (143 <span className={styles.current} aria-current={isLast ? "page" : undefined}>144 {icon}145 {item.label}146 </span>147 );148 }149 return (150 <a href={item.href} className={styles.link} onClick={(e) => activate(e, item, index)}>151 {icon}152 <span className={styles.text}>{item.label}</span>153 </a>154 );155 };156 157 return (158 <nav159 aria-label={ariaLabel}160 className={`${styles.root} ${className}`.trim()}161 data-size={size}162 style={{ "--mf-bc-accent": accentColor, ...style } as React.CSSProperties}163 >164 <ol className={styles.list}>165 {visible.map(({ item, index }, pos) => (166 <React.Fragment key={`${item.label}-${index}`}>167 {pos > 0 && <Separator kind={separator} />}168 <li className={styles.item}>{renderLink(item, index)}</li>169 {pos === 0 && hidden.length > 0 && (170 <>171 <Separator kind={separator} />172 <li ref={wrapRef} className={`${styles.item} ${styles.more}`}>173 <button174 ref={triggerRef}175 type="button"176 className={styles.ellipsis}177 aria-label={`Show ${hidden.length} more`}178 aria-haspopup="menu"179 aria-expanded={open}180 aria-controls={open ? menuId : undefined}181 onClick={() => setOpen((o) => !o)}182 onKeyDown={(e) => {183 if (e.key === "ArrowDown") {184 e.preventDefault();185 setOpen(true);186 }187 }}188 >189 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">190 <circle cx="5" cy="12" r="1.8" />191 <circle cx="12" cy="12" r="1.8" />192 <circle cx="19" cy="12" r="1.8" />193 </svg>194 </button>195 <AnimatePresence>196 {open && (197 <motion.ul198 id={menuId}199 role="menu"200 aria-label="Hidden pages"201 className={styles.menu}202 onKeyDown={onMenuKey}203 initial={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.97 }}204 animate={{ opacity: 1, y: 0, scale: 1 }}205 exit={{ opacity: 0, y: reduce ? 0 : -4, scale: reduce ? 1 : 0.98 }}206 transition={{ duration: reduce ? 0.1 : 0.18, ease: [0.16, 1, 0.3, 1] }}207 >208 {hidden.map((h, i) => (209 <li key={`${h.label}-${i}`} role="none">210 <a211 ref={(el) => {212 itemRefs.current[i] = el;213 }}214 role="menuitem"215 tabIndex={-1}216 href={h.href ?? "#"}217 className={styles.menuItem}218 onClick={(e) => {219 activate(e, h, i + 1);220 close(true);221 }}222 >223 {h.label}224 </a>225 </li>226 ))}227 </motion.ul>228 )}229 </AnimatePresence>230 </li>231 </>232 )}233 </React.Fragment>234 ))}235 </ol>236 </nav>237 );238}