Breadcrumb Trail Append
Going one level deeper slides a new crumb in from the right while the trail behind it recedes.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Breadcrumb Trail Append
*
* Drilling one level deeper slides a new crumb in from the right while
* the trail behind it slides left and recedes. Going back up removes the
* crumb and lets the rest fall back into place.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the trail reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `root`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type BreadcrumbTrailAppendProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Label of the first, permanent crumb. */
root?: string;
/** Notified with the full trail after every move. */
onNavigate?: (trail: string[]) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** How far the arriving crumb travels, in pixels. */
travel: number;
fade: number;
};
// Quality rule: a breadcrumb is small type, and small type that bounces
// is unreadable for the frames it is bouncing. Every spring is at or above
// a 0.8 damping ratio, and the crumb labels use layout="position" so they
// translate without their boxes stretching the glyphs. Variants change how
// far the new crumb comes from, not how it lands.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short step in. For deep hierarchies that get walked constantly.
subtle: {
spring: { type: "spring", stiffness: 620, damping: 48 },
travel: 8,
fade: 0.12,
},
// Enough travel to show the direction of the move. All-purpose.
default: {
spring: { type: "spring", stiffness: 500, damping: 42 },
travel: 14,
fade: 0.16,
},
// A longer entrance, for trails that change rarely enough to be worth
// watching.
playful: {
spring: { type: "spring", stiffness: 420, damping: 36 },
travel: 20,
fade: 0.18,
},
};
/** Beyond this, the oldest crumbs fold into a single overflow control. */
const MAX_CRUMBS = 4;
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const CHILDREN: Record<string, readonly string[]> = {
Workspace: ["Documents", "Analytics", "Support"],
Documents: ["Contracts", "Invoices", "Templates"],
Analytics: ["Funnels", "Retention"],
Support: ["Open requests", "Resolved"],
Contracts: ["Vendor agreements", "Renewals"],
Invoices: ["First quarter", "Second quarter"],
};
const LEAF_FILES: readonly { name: string; meta: string }[] = [
{ name: "Summary.pdf", meta: "1.2 MB · Priya Raman" },
{ name: "Line items.csv", meta: "84 KB · Automation" },
];
function Chevron({ size = 12, opacity = 1 }: { size?: number; opacity?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity, flexShrink: 0 }}
aria-hidden
>
<path d="M7.5 4.5 13 10l-5.5 5.5" />
</svg>
);
}
export default function BreadcrumbTrailAppend({
variant = "default",
root = "Workspace",
onNavigate,
}: BreadcrumbTrailAppendProps) {
const [trail, setTrail] = useState<string[]>([root, "Documents"]);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const move = (next: string[]) => {
setTrail(next);
onNavigate?.(next);
};
const current = trail[trail.length - 1];
const children = CHILDREN[current] ?? [];
// Reduced motion: crumbs still appear and disappear in the right order,
// they just stop travelling to get there.
const layoutTransition = reduceMotion ? { duration: 0 } : cfg.spring;
const enter = reduceMotion
? { opacity: 0 }
: { opacity: 0, x: cfg.travel };
// Only the tail of a long trail is shown; everything before it folds
// into one control that steps back to the deepest hidden level.
const overflow = Math.max(0, trail.length - MAX_CRUMBS);
const visible = trail.slice(overflow);
return (
<div
style={{
width: 372,
height: 244,
display: "flex",
flexDirection: "column",
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 10px 28px rgba(0,0,0,0.16)",
overflow: "hidden",
}}
>
<nav
aria-label="Breadcrumb"
style={{
display: "flex",
alignItems: "center",
gap: 4,
height: 46,
padding: "0 14px",
borderBottom: `1px solid ${tone(12)}`,
// The trail is measured against this bar, never the viewport:
// the row is clipped here so an arriving crumb slides in from
// the edge of the bar rather than from off-screen.
overflow: "hidden",
}}
>
{overflow > 0 && (
<>
<button
type="button"
onClick={() => move(trail.slice(0, overflow))}
aria-label={`Show ${overflow} earlier level${overflow > 1 ? "s" : ""}`}
style={{
padding: "3px 7px",
borderRadius: 7,
border: 0,
background: tone(10),
color: "inherit",
fontSize: 12,
fontWeight: 700,
fontFamily: "inherit",
lineHeight: 1.2,
cursor: "pointer",
opacity: 0.6,
}}
>
…
</button>
<Chevron opacity={0.32} />
</>
)}
{/* popLayout takes a removed crumb out of flow the instant it
starts leaving, so the crumbs after it begin closing the gap
immediately instead of waiting for the exit to finish. */}
<AnimatePresence initial={false} mode="popLayout">
{visible.map((crumb, index) => {
const depth = overflow + index;
const isLast = depth === trail.length - 1;
return (
<motion.div
key={`${depth}-${crumb}`}
layout
initial={enter}
animate={{ opacity: 1, x: 0 }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.1 } }
: {
opacity: 0,
x: cfg.travel * 0.6,
transition: { duration: cfg.fade, ease: "easeIn" },
}
}
transition={layoutTransition}
style={{
display: "flex",
alignItems: "center",
gap: 4,
flexShrink: 0,
}}
>
{index > 0 && <Chevron opacity={0.32} />}
<motion.button
type="button"
// Text moves, it never stretches: layout="position"
// animates the label's position while leaving its box —
// and therefore its glyphs — untouched.
layout="position"
transition={layoutTransition}
onClick={() => move(trail.slice(0, depth + 1))}
aria-current={isLast ? "page" : undefined}
disabled={isLast}
style={{
padding: "3px 6px",
borderRadius: 7,
border: 0,
background: "transparent",
color: "inherit",
// Depth is expressed with opacity, not colour: one
// compositor property, correct on any palette.
opacity: isLast ? 1 : 0.5,
fontSize: 12.5,
fontWeight: isLast ? 650 : 500,
fontFamily: "inherit",
whiteSpace: "nowrap",
cursor: isLast ? "default" : "pointer",
}}
>
{crumb}
</motion.button>
</motion.div>
);
})}
</AnimatePresence>
</nav>
<div style={{ flex: 1, padding: "10px 8px", overflow: "hidden" }}>
{children.length > 0 ? (
children.map((child) => (
<button
key={child}
type="button"
onClick={() => move([...trail, child])}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "9px 10px",
borderRadius: 10,
border: 0,
background: "transparent",
color: "inherit",
fontSize: 12.5,
fontWeight: 550,
fontFamily: "inherit",
textAlign: "left",
cursor: "pointer",
}}
>
<svg
width="17"
height="17"
viewBox="0 0 20 20"
fill="none"
stroke={ACCENT}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M2.6 6.2a1.6 1.6 0 0 1 1.6-1.6h3l1.6 2h6.6a1.6 1.6 0 0 1 1.6 1.6v6.2a1.6 1.6 0 0 1-1.6 1.6H4.2a1.6 1.6 0 0 1-1.6-1.6z" />
</svg>
<span style={{ flex: 1 }}>{child}</span>
<Chevron opacity={0.3} />
</button>
))
) : (
<div style={{ padding: "2px 10px" }}>
<div style={{ fontSize: 11.5, opacity: 0.5, marginBottom: 8 }}>
End of the trail — 2 files
</div>
{LEAF_FILES.map((file) => (
<div
key={file.name}
style={{
display: "flex",
flexDirection: "column",
gap: 2,
padding: "8px 0",
borderTop: `1px solid ${tone(10)}`,
}}
>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>
{file.name}
</span>
<span style={{ fontSize: 11, opacity: 0.5 }}>{file.meta}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}About this pattern
Orientation for anything with a hierarchy: folders, categories, settings sections. The crumb that arrives is the one that moved you, so it enters from the direction of travel while the existing trail shifts left and dims — position and opacity together say both where you are and which way you came. The labels are the delicate part: they use layout="position" so the row can reflow around them without stretching a single glyph, and popLayout takes a removed crumb out of flow the moment it starts leaving so the rest close the gap immediately rather than waiting. Past four levels the oldest crumbs fold into one overflow control instead of letting the bar scroll away from the current location.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- File browser
The folder path grows and truncates as you move through directories.
Related patterns
- Nested Menu DrillA submenu pushes the parent list aside inside the same panel, and the panel resizes to fit it.
- Dropdown Menu OpenA menu unfolds from the corner of the control that opened it, with its items arriving a frame apart.
- Route Progress TopA top-edge bar advances during navigation and completes with a quick finish.