MynduiMyndui
0

Anatomy of Breadcrumbs

How a flat items array becomes a trail of pills with one shared hover highlight, a spring-loaded overflow popover, and a mobile collapse that never touches JavaScript.

01/04Pills and chevrons
Link crumb
navigable — text-muted-foreground
Current crumb
aria-current='page', filled bg-muted
Separator
aria-hidden chevron, one per gap
tsx
<motion.lilayoutinitial={reduceMotion ? false : { opacity: 0, x: -6 }}animate={{ opacity: 1, x: 0 }}exit={reduceMotion ? { opacity: 0 } : { opacity: 0, x: -6 }}transition={spring}>{isLast || !item.href ? (  <span aria-current={isLast ? "page" : undefined} className="... bg-muted text-foreground">    {item.label}  </span>) : (  <a href={item.href} className="... text-muted-foreground hover:text-foreground">    {item.label}  </a>)}</motion.li>
01

Pills and chevrons

A breadcrumb trail looks like the simplest nav in the system — links, a separator, done. The interesting part shows up at the edges: what happens when the pointer moves along the row, and what happens when the trail is longer than the screen. Breadcrumbs answers both without adding a single extra DOM node per crumb.

Every crumb but the last is an <a> tinted text-muted-foreground; the last is a plain <span> filled bg-muted and marked aria-current="page". An aria-hidden chevron sits between each pair. The whole row is a motion.ol so AnimatePresence can cross-fade crumbs in and out when the items array changes:

02/04One hover pill

layoutId spring — stiffness 520, damping 32

tsx
const HoverPill = ({ active }: { active: boolean }) =>active ? (  <motion.span    layoutId={hoverId}    transition={{ type: "spring", stiffness: 520, damping: 32 }}    className="absolute inset-0 rounded-lg bg-accent"  />) : null;
02

One hover pill

Hovering a crumb doesn't toggle that crumb's own background — every link shares a single motion.span bound by one layoutId. It only renders under whichever crumb is currently hovered, so moving the pointer along the trail springs that one element from box to box:

stiffness: 520, damping: 32 is the same snap used across Myndui's small UI springs — fast enough that it never lags a fast mouse sweep, damped enough that it doesn't wobble on arrival. onMouseEnter sets hovered to the crumb's key; leaving the <nav> entirely clears it in one onMouseLeave at the root instead of per-crumb listeners.

03/04Collapsing the middle

popover spring — scale 0.92 → 1, y -4 → 0

tsx
const tailCount = maxItems - 1;const head = items.slice(0, 1).map(toEntry);const hidden = items.slice(1, items.length - tailCount);const tail = items.slice(items.length - tailCount).map(toEntry);return [...head, { kind: "ellipsis", hidden }, ...tail];
03

Collapsing the middle

Pass maxItems, and once items.length exceeds it the middle crumbs fold into a single ellipsis button — head crumb, , then the last maxItems − 1 crumbs. Clicking the ellipsis doesn't navigate; it opens a popover listing the crumbs it swallowed:

The popover springs in on scale: 0.92 → 1, y: -4 → 0 — the same overlay entrance used by every other panel in the system — and a mousedown listener outside popRef closes it. Below maxItems, a second, CSS-only collapse kicks in on narrow viewports: crumbs past the first hide under max-sm:hidden and a static takes their place. It has to be CSS, not a useEffect viewport check — in the docs preview the component runs inside a portaled iframe but at the parent's JS realm, so only a media query actually sees the iframe's width.

04/04Result

Viewing /billing

maxItems={3}

04

The result

One items array, one shared hover pill, and a collapse that degrades gracefully from maxItems down to a CSS ellipsis on narrow screens — the trail never needs more markup than the pieces above.

Accessibility

There's no extra label announcing the current page — the last crumb's aria-current="page" is the whole signal, exactly like a native breadcrumb should read to a screen reader. Separators are aria-hidden so they're invisible to assistive tech, and every link still gets the standard focus-visible:ring-2 — no custom keyboard handling needed because crumbs are just anchors.

Motion Score

BreadcrumbsSSCompositor-only
SscaleScale spring / press
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →