All patterns

Breadcrumb Trail Append

Going one level deeper slides a new crumb in from the right while the trail behind it recedes.

navigationsubtleminimalinteraction · finite · intermediate · ~0.3s
Interactive · click to play
Variant

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.

324 lines · react + motion only
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,
              }}
            >
              &#8230;
            </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.

Folder and file browserCategory drill-downSettings section pathDocumentation hierarchy

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Files
    Recent
    Shared
    Starred
    Trash
    FilesNew
    Brand refresh.figPriya Raman · 2.4 MB
    Q3 planning.pdfMarcus Bell · 840 KB
    Supplier contract.docxDana Whitfield · 96 KB
    Photography brief.mdNils Bergström · 12 KB
    Invoice 4821.pdfBilling · 64 KB
    File browser

    The folder path grows and truncates as you move through directories.

Related patterns