UINavigationmotion/react

Breadcrumb

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 props
4

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add breadcrumb

Writes 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.

example.tsx
1import { Breadcrumb } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Breadcrumb />6  );7}

Props

PropTypeDefault
items

Pages from root to the current page; the last item is the current page.

{ label: string; href?: string }[]6-level demo trail
separator

Glyph rendered between items.

'chevron' | 'slash' | 'dot' | 'arrow''chevron'
maxItems

When the trail is longer, middle items collapse into an ellipsis menu (first item plus the last maxItems - 1 stay visible).

number4
collapsible

Enable collapsing of middle items.

booleantrue
showHomeIcon

Show a home icon before the first label.

booleantrue
size

Text size and spacing preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of the hover underline and menu highlight.

string'#6366f1'
ariaLabel

Accessible label of the nav landmark.

string'Breadcrumb'
onNavigate

Called when a link is activated. Links with href '#' are not followed.

(item: BreadcrumbItem, index: number) => voidundefined
className

Class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/breadcrumb.tsx
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}