Exclusivebeta

Crumbs — <mv-crumbs>

A trail of where the user has actually been, for apps where people hop between records (CRM, support desk, admin console, docs, code browser, back office), not where the page sits in the hierarchy. Every visit, from visit({ id, label, href, kind, icon }), same-origin link clicks (track="links") or history changes (track="history", Navigation API with popstate/hashchange fallback), drops a crumb with its kind icon at the end of a compact horizontal trail; a revisit moves the crumb forward instead of duplicating it (or refreshes it in place, or repeats it: revisit). Older crumbs gradually fade, shrink and get their label nibbled shorter as time passes (lifetime) and as newer ones pile up (max), and fully faded ones fold into a “+12 earlier” menu listing every place with its time since visit, so the trail stays short and relevant; it never scrolls either, since the oldest crumbs fold early when the width runs out. Pinned crumbs never fade and sit first, like pinned tabs. Opening a crumb from the trail is a step: it becomes current in place and the trail never reshuffles under the pointer. Hovering a crumb opens a small card with the full label, kind, “Visited 4 minutes ago”, Pin and Remove; the keyboard gets roving focus, P / Delete and Alt+[ / Alt+] to walk back and forth along the trail. mv-crumb-open is cancelable for SPA routers, visits can be vetoed or relabeled through mv-crumb, and the trail can persist per session or per user in browser storage (opt-in) or through your server via the crumbs property and mv-crumbs-change.

CategoryNavigation
TypeWeb Component (<mv-crumbs>)
Statusbeta
Keywordsexclusive, culture, breadcrumb, history, recent, recently-viewed, visited, trail, navigation, back-forward, pin, crm, support-desk, admin, spa-router, persistence, fade, keyboard

When to use

Avoid when

Install

node scripts/add.mjs crumbs --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["crumbs"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, core/position.js, components/crumbs/crumbs.js, components/crumbs/crumbs.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="cr-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #cr-demo { display:grid; gap:1.25rem; align-content:start }
    #cr-demo .cr-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #cr-demo .cr-bar { display:flex; align-items:center; gap:1rem; height:3.25rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
    #cr-demo .cr-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em; white-space:nowrap }
    #cr-demo .cr-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #cr-demo .cr-logo svg { width:1rem; height:1rem }
    #cr-demo .cr-search { display:flex; align-items:center; gap:.5rem; flex:0 1 18rem; height:2rem; margin-inline-start:auto; padding:0 .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); color:var(--mv-fg-subtle); font-size:.8125rem }
    #cr-demo .cr-search svg { width:.9375rem; height:.9375rem; flex:none }
    #cr-demo .cr-me { display:grid; place-items:center; flex:none; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.75rem; font-weight:600 }
    #cr-demo .cr-trailbar { display:flex; align-items:center; gap:.75rem; min-height:2.875rem; padding:.5rem 1rem .5rem 1.125rem; border-bottom:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #cr-demo .cr-trailbar mv-crumbs { flex:1 1 auto }
    #cr-demo .cr-body { display:grid; grid-template-columns:11rem minmax(0,1fr); min-height:22rem }
    #cr-demo .cr-side { display:grid; align-content:start; gap:.125rem; padding:.875rem .625rem; border-right:1px solid var(--mv-border) }
    #cr-demo .cr-side a { display:flex; align-items:center; gap:.5rem; padding:.4375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
    #cr-demo .cr-side a:hover { background:var(--mv-bg-muted); color:var(--mv-fg) }
    #cr-demo .cr-side a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
    #cr-demo .cr-side a:focus-visible, #cr-demo .cr-rel a:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
    #cr-demo .cr-side svg { width:1rem; height:1rem; flex:none }
    #cr-demo .cr-main { min-width:0; padding:1.25rem 1.375rem }
    #cr-demo .cr-head { display:flex; align-items:flex-start; gap:.875rem }
    #cr-demo .cr-ico { display:grid; place-items:center; flex:none; width:2.5rem; height:2.5rem; border-radius:var(--mv-radius-lg); background:color-mix(in oklab, var(--tone) 13%, var(--mv-surface)); color:var(--tone) }
    #cr-demo .cr-ico svg { width:1.25rem; height:1.25rem }
    #cr-demo .cr-head h3 { margin:0; font-size:1.125rem; letter-spacing:-.015em }
    #cr-demo .cr-head p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #cr-demo .cr-head .mv-badge { margin-inline-start:auto }
    #cr-demo .cr-fields { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.625rem; margin:1.125rem 0 0 }
    #cr-demo .cr-fields div { min-width:0; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle) }
    #cr-demo .cr-fields dt { color:var(--mv-fg-muted); font-size:.6875rem }
    #cr-demo .cr-fields dd { margin:.125rem 0 0; font-size:.875rem; font-weight:600; font-variant-numeric:tabular-nums; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #cr-demo .cr-k { margin:1.25rem 0 .5rem; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
    #cr-demo .cr-rel { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.5rem; margin:0; padding:0; list-style:none }
    #cr-demo .cr-rel a { display:flex; align-items:center; gap:.625rem; min-width:0; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); color:inherit; text-decoration:none; transition:border-color var(--mv-duration-fast), background-color var(--mv-duration-fast) }
    #cr-demo .cr-rel a:hover { border-color:var(--mv-border-strong); background:var(--mv-bg-subtle) }
    #cr-demo .cr-rel .cr-ico { width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md) }
    #cr-demo .cr-rel .cr-ico svg { width:.9375rem; height:.9375rem }
    #cr-demo .cr-rel b { display:block; font-size:.8125rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #cr-demo .cr-rel small { display:block; color:var(--mv-fg-muted); font-size:.6875rem }
    #cr-demo .cr-rel span.cr-t { min-width:0 }
    #cr-demo .cr-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #cr-demo .cr-actions { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
    #cr-demo .cr-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
    #cr-demo .cr-log { min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
    #cr-demo .mv-choice { font-size:.8125rem }
    #cr-demo .cr-docs { display:grid; gap:.875rem; padding:1rem 1.125rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #cr-demo .cr-docs-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
    #cr-demo .cr-docs-head strong { font-size:.9375rem; letter-spacing:-.01em }
    #cr-demo .cr-docs-head span { color:var(--mv-fg-muted); font-size:.75rem }
    #cr-demo .cr-docs-links { display:flex; flex-wrap:wrap; gap:.375rem; margin:0; padding:0; list-style:none }
    #cr-demo .cr-docs-links a { display:inline-flex; align-items:center; height:1.875rem; padding:0 .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); color:var(--mv-fg); font-size:.8125rem; text-decoration:none; font-family:var(--mv-font-mono) }
    #cr-demo .cr-docs-links a:hover { background:var(--mv-bg-muted) }
    #cr-demo .cr-docs-links a:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
    @media (max-width:44rem) {
      #cr-demo .cr-body { grid-template-columns:minmax(0,1fr) }
      #cr-demo .cr-side { display:none }
      #cr-demo .cr-fields, #cr-demo .cr-rel { grid-template-columns:minmax(0,1fr) }
      #cr-demo .cr-search { display:none }
      #cr-demo .cr-me { margin-inline-start:auto }
    }
  </style>

  <!-- Main: a CRM where people hop between records. The trail follows real visits, not the hierarchy. -->
  <section class="cr-app" id="cr-app" aria-label="Ridgeline CRM">
    <header class="cr-bar">
      <span class="cr-brand">
        <span class="cr-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 18 6-8 4 5 3-4 5 7z"/></svg></span>
        Ridgeline
      </span>
      <span class="cr-search" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>Search customers, deals, invoices…</span>
      <span class="cr-me" aria-hidden="true">LM</span>
    </header>
    <div class="cr-trailbar">
      <mv-crumbs id="cr-trail" track="links" track-root="#cr-app" max="5" lifetime="45m"></mv-crumbs>
    </div>
    <div class="cr-body">
      <nav class="cr-side" aria-label="Sections">
        <a href="#/customers" data-crumb="off"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/><path d="M16 9h2a2 2 0 0 1 2 2v10"/><path d="M8 7h4M8 11h4M8 15h4M3 21h18"/></svg>Customers</a>
        <a href="#/deals" data-crumb="off"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="4.5"/></svg>Deals</a>
        <a href="#/invoices" data-crumb="off" aria-current="page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2z"/><path d="M9 8h6M9 12h6M9 16h3"/></svg>Invoices</a>
        <a href="#/tickets" data-crumb="off"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16v3.5a2.5 2.5 0 0 0 0 5V18H4v-3.5a2.5 2.5 0 0 0 0-5z"/></svg>Tickets</a>
      </nav>
      <main class="cr-main" id="cr-view" aria-live="off"></main>
    </div>
    <div class="cr-controls">
      <span class="cr-actions">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cr-ff">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 6 7 6-7 6zM13 6l7 6-7 6z"/></svg>
          Fast-forward 10 min
        </button>
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cr-move" checked> Revisits jump to the end</label>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="cr-clear">Clear trail</button>
      </span>
      <p class="cr-hint"><kbd class="mv-kbd">Alt</kbd>+<kbd class="mv-kbd">[</kbd> / <kbd class="mv-kbd">Alt</kbd>+<kbd class="mv-kbd">]</kbd> step along the trail · <kbd class="mv-kbd">P</kbd> pin · <kbd class="mv-kbd">Del</kbd> remove</p>
      <p class="cr-log" id="cr-log" aria-live="polite">Open related records to drop crumbs.</p>
    </div>
  </section>

  <!-- Plain variant: an API reference where the trail fades within two minutes. -->
  <section class="cr-docs" id="cr-docs" aria-label="API reference">
    <div class="cr-docs-head">
      <strong>API reference</strong>
      <span>Plain variant · small · 4 crumbs · fades within 2 minutes</span>
    </div>
    <mv-crumbs id="cr-doc-trail" data-variant="plain" data-size="sm" track="links" track-root="#cr-docs" max="4" lifetime="2m"></mv-crumbs>
    <ul class="cr-docs-links">
      <li><a href="#/docs/authentication" data-crumb-id="/docs/authentication" data-crumb-kind="doc">Authentication</a></li>
      <li><a href="#/docs/webhooks" data-crumb-id="/docs/webhooks" data-crumb-kind="doc">Webhooks</a></li>
      <li><a href="#/docs/rate-limits" data-crumb-id="/docs/rate-limits" data-crumb-kind="doc">Rate limits</a></li>
      <li><a href="#/docs/errors" data-crumb-id="/docs/errors" data-crumb-kind="doc">Errors</a></li>
      <li><a href="#/docs/pagination" data-crumb-id="/docs/pagination" data-crumb-kind="doc">Pagination</a></li>
      <li><a href="#/docs/idempotency" data-crumb-id="/docs/idempotency" data-crumb-kind="doc">Idempotency keys</a></li>
      <li><a href="#/docs/sdk-node" data-crumb-id="/docs/sdk-node" data-crumb-kind="code">Node.js SDK</a></li>
    </ul>
  </section>

  <script type="module">
    const trail = document.getElementById("cr-trail");
    const docTrail = document.getElementById("cr-doc-trail");
    const view = document.getElementById("cr-view");
    const log = document.getElementById("cr-log");
    const MIN = 60_000;

    const ICON = {
      customer: '<path d="M4 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/><path d="M16 9h2a2 2 0 0 1 2 2v10"/><path d="M8 7h4M8 11h4M8 15h4M3 21h18"/>',
      contact: '<circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/>',
      deal: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="4.5"/><circle cx="12" cy="12" r="1" fill="currentColor"/>',
      invoice: '<path d="M6 3h12v18l-3-2-3 2-3-2-3 2z"/><path d="M9 8h6M9 12h6M9 16h3"/>',
      ticket: '<path d="M4 6h16v3.5a2.5 2.5 0 0 0 0 5V18H4v-3.5a2.5 2.5 0 0 0 0-5z"/><path d="M14 6v2M14 11v2M14 16v2"/>',
    };
    const KINDS = {
      customer: { label: "Customer", icon: "building", color: "var(--mv-info)", path: "customers" },
      contact: { label: "Contact", icon: "person", color: "var(--mv-accent)", path: "contacts" },
      deal: { label: "Deal", icon: "target", color: "var(--mv-success)", path: "deals" },
      invoice: { label: "Invoice", icon: "receipt", color: "var(--mv-warning)", path: "invoices" },
      ticket: { label: "Ticket", icon: "ticket", color: "var(--mv-danger)", path: "tickets" },
    };
    trail.kinds = KINDS;

    // A tiny in-memory CRM.
    const DB = {
      "globex": { kind: "customer", title: "Globex Corporation", sub: "Enterprise · Springfield, US", badge: "Active", fields: [["ARR", "$184,000"], ["Owner", "Lena Moreau"], ["Health", "Good"]], rel: ["inv-2031", "tk-4471", "c-okafor"] },
      "inv-2031": { kind: "invoice", title: "INV-2031", sub: "Globex Corporation · issued Aug 29, 2026", badge: "Paid", fields: [["Amount", "$46,000.00"], ["Due", "Sep 28, 2026"], ["Method", "Wire"]], rel: ["globex"] },
      "tk-4471": { kind: "ticket", title: "#4471 SSO login loop", sub: "Globex Corporation · priority High", badge: "Open", fields: [["Assignee", "Mateo Silva"], ["Opened", "Sep 22, 2026"], ["SLA", "3 h left"]], rel: ["globex", "c-okafor"] },
      "initech": { kind: "deal", title: "Initech expansion", sub: "Initech · Proposal stage", badge: "Proposal", fields: [["Value", "$96,500"], ["Close", "Nov 14, 2026"], ["Probability", "40%"]], rel: ["umbrella"] },
      "umbrella": { kind: "customer", title: "Umbrella Health", sub: "Mid-market · Toronto, CA", badge: "Trial", fields: [["ARR", "$0"], ["Owner", "Priya Nair"], ["Trial ends", "Oct 9, 2026"]], rel: ["initech", "inv-2044"] },
      "inv-2044": { kind: "invoice", title: "INV-2044", sub: "Umbrella Health · issued Sep 18, 2026", badge: "Overdue", fields: [["Amount", "$8,250.00"], ["Due", "Sep 21, 2026"], ["Method", "Card"]], rel: ["umbrella"] },
      "northwind": { kind: "customer", title: "Northwind Traders", sub: "Enterprise · Seattle, US", badge: "Active", fields: [["ARR", "$212,400"], ["Owner", "Lena Moreau"], ["Health", "At risk"]], rel: ["nw-renewal", "c-tanaka", "inv-2048", "tk-4502"] },
      "nw-renewal": { kind: "deal", title: "Northwind renewal FY27", sub: "Northwind Traders · Negotiation", badge: "Negotiation", fields: [["Value", "$224,000"], ["Close", "Oct 31, 2026"], ["Probability", "75%"]], rel: ["northwind", "c-tanaka", "inv-2048"] },
      "c-tanaka": { kind: "contact", title: "Aiko Tanaka", sub: "VP Operations · Northwind Traders", badge: "Champion", fields: [["Email", "[email protected]"], ["Phone", "+1 206 555 0148"], ["Time zone", "PT"]], rel: ["northwind", "nw-renewal"] },
      "inv-2048": { kind: "invoice", title: "INV-2048", sub: "Northwind Traders · draft", badge: "Draft", fields: [["Amount", "$17,400.00"], ["Due", "Oct 15, 2026"], ["Lines", "3"]], rel: ["northwind", "nw-renewal", "c-tanaka", "tk-4502"] },
      "tk-4502": { kind: "ticket", title: "#4502 Export to CSV times out", sub: "Northwind Traders · priority Normal", badge: "Pending", fields: [["Assignee", "Mateo Silva"], ["Opened", "Sep 23, 2026"], ["SLA", "1 d left"]], rel: ["northwind", "inv-2048"] },
      "c-okafor": { kind: "contact", title: "Chidi Okafor", sub: "IT Director · Globex Corporation", badge: "Admin", fields: [["Email", "[email protected]"], ["Phone", "+1 312 555 0190"], ["Time zone", "CT"]], rel: ["globex", "tk-4471"] },
    };
    const hrefOf = (id) => `#/${KINDS[DB[id].kind].path}/${id}`;
    const crumbOf = (id) => ({ id, label: DB[id].title, href: hrefOf(id), kind: DB[id].kind });

    const el = (tag, attrs = {}, ...kids) => {
      const n = document.createElement(tag);
      for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v);
      n.append(...kids);
      return n;
    };
    const icon = (kind) => {
      const t = document.createElement("template");
      t.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[kind]}</svg>`;
      return t.content.firstElementChild;
    };
    const ico = (kind) => { const s = el("span", { class: "cr-ico" }, icon(kind)); s.style.setProperty("--tone", KINDS[kind].color); return s; };

    function show(id) {
      const r = DB[id];
      if (!r) return;
      const head = el("div", { class: "cr-head" }, ico(r.kind),
        el("div", {}, el("h3", {}, r.title), el("p", {}, `${KINDS[r.kind].label} · ${r.sub}`)),
        el("span", { class: "mv-badge", "data-variant": "secondary", "data-shape": "pill" }, r.badge));
      const fields = el("dl", { class: "cr-fields" }, ...r.fields.map(([k, v]) => el("div", {}, el("dt", {}, k), el("dd", {}, v))));
      const rel = el("ul", { class: "cr-rel" }, ...r.rel.map((rid) => {
        const o = DB[rid];
        return el("li", {}, el("a", { href: hrefOf(rid), "data-crumb-id": rid, "data-crumb-kind": o.kind, "data-crumb-label": o.title },
          ico(o.kind), el("span", { class: "cr-t" }, el("b", {}, o.title), el("small", {}, `${KINDS[o.kind].label} · ${o.badge}`))));
      }));
      view.replaceChildren(head, fields, el("p", { class: "cr-k" }, "Related"), rel);
      for (const a of document.querySelectorAll("#cr-app .cr-side a")) {
        a.toggleAttribute("aria-current", a.getAttribute("href") === `#/${KINDS[r.kind].path}`);
      }
    }

    // Seed: an afternoon of hopping between records, at realistic ages.
    const seed = [
      ["globex", 52], ["inv-2031", 47], ["tk-4471", 40, true], ["initech", 31], ["umbrella", 23],
      ["inv-2044", 16], ["northwind", 10], ["nw-renewal", 6], ["c-tanaka", 3], ["inv-2048", 0],
    ];
    trail.crumbs = seed.map(([id, min, pinned]) => ({ ...crumbOf(id), visitedAt: Date.now() - min * MIN, pinned: Boolean(pinned) }));
    show("inv-2048");

    // The demo is a single-page app: links route in place (the trail records them on its own).
    document.getElementById("cr-app").addEventListener("click", (e) => {
      const a = e.target.closest("a[href^='#/']");
      if (!a || trail.contains(a)) return;
      e.preventDefault();
      if (a.dataset.crumbId) show(a.dataset.crumbId);
    });
    // Opening a crumb: route in place, then confirm the visit (kept in place: it is a step).
    trail.addEventListener("mv-crumb-open", (e) => {
      e.preventDefault();
      const { crumb, source, direction } = e.detail;
      show(crumb.id);
      trail.visit(crumbOf(crumb.id));
      say(`Opened “${crumb.label}” (${direction ?? source}).`);
    });
    trail.addEventListener("mv-crumb", (e) => {
      const { crumb, revisit, step } = e.detail;
      if (!step) say(revisit ? `Revisited “${crumb.label}”.` : `New crumb: “${crumb.label}”.`);
    });
    trail.addEventListener("mv-crumb-pin", (e) => say(`${e.detail.pinned ? "Pinned" : "Unpinned"} “${e.detail.crumb.label}”.`));
    trail.addEventListener("mv-crumb-remove", (e) => { if (e.detail.reason === "user") say(`Removed “${e.detail.crumb.label}”.`); });

    function say(text) {
      const t = new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
      log.textContent = `${t} · ${text}`;
    }

    document.getElementById("cr-ff").addEventListener("click", () => {
      trail.crumbs = trail.crumbs.map((c) => ({ ...c, visitedAt: c.visitedAt - 10 * MIN }));
      say("Ten minutes later: older crumbs fade.");
    });
    document.getElementById("cr-move").addEventListener("change", (e) => {
      trail.revisit = e.target.checked ? "move" : "stay";
      say(e.target.checked ? "Revisits move the crumb to the end." : "Revisits refresh the crumb in place.");
    });
    document.getElementById("cr-clear").addEventListener("click", () => {
      const n = trail.clear();
      say(`Cleared ${n} unpinned crumbs.`);
    });

    // Docs: a second, quieter trail.
    docTrail.crumbs = [
      { id: "/docs/errors", label: "Errors", href: "#/docs/errors", kind: "doc", visitedAt: Date.now() - 80_000 },
      { id: "/docs/webhooks", label: "Webhooks", href: "#/docs/webhooks", kind: "doc", visitedAt: Date.now() - 45_000 },
      { id: "/docs/rate-limits", label: "Rate limits", href: "#/docs/rate-limits", kind: "doc", visitedAt: Date.now() - 12_000 },
    ];
    document.getElementById("cr-docs").addEventListener("click", (e) => {
      if (e.target.closest("a[href^='#/']")) e.preventDefault();
    });
    docTrail.addEventListener("mv-crumb-open", (e) => {
      e.preventDefault();
      docTrail.visit({ id: e.detail.crumb.id, label: e.detail.crumb.label, href: e.detail.crumb.href, kind: e.detail.crumb.kind });
    });
  </script>
</div>

Cultural reference

Hansel and Gretel — Brothers Grimm (1812, fable). Lost in the forest, the boy drops breadcrumbs to find the way back home, but birds eat them, so the trail of where he has been fades behind him. In the UI, every place the user actually visits drops a crumb on a trail back through their work; older crumbs fade and fold away as time passes and new ones accumulate, unless the user pins the ones worth keeping.

API

Attributes

NameTypeDefaultDescription
maxnumber6How many unpinned crumbs (the current one included) stay on the trail. Each newer visit pushes the others one step toward folding: the crumb of rank max folds into the “earlier” menu. Pinned crumbs don't count. When the trail is too narrow, fewer are shown (the oldest fold early and come back when room returns); if even the pinned and current crumbs don't fit, their labels shrink and the button shows “+N” only.
lifetimetime ("30m", "2h", "90s", ms number)30mTime after which an unpinned crumb has fully faded and folds, even if few visits followed. Freshness is the lower of the time left and the rank left, so a crumb fades with time and with newer visits.
revisitmove | stay | repeatmoveWhat a visit to a place already on the trail does: move it to the end (refreshed), refresh it in place, or add a new crumb (a strict chronological trail; the same place twice in a row still refreshes). Steps from the trail itself (click, keyboard, Alt+[ / Alt+]) always refresh in place.
track"links" | "history" | "links history"Automatic visits. links: primary same-origin clicks on a[href] outside the trail (routers calling preventDefault still count; target=_blank, download, modified clicks and plain #section anchors are skipped; hash routes like #/deals/42 count). history: the current page on first connection, then every history entry change (Navigation API, else popstate + hashchange), labeled with document.title (re-read 300 ms later for routers that set it late). Without track, call visit().
track-rootCSS selectorOnly links inside this element are tracked (default: the whole document).
persiststringStorage key (opt-in): the trail, its current crumb and a pending step are saved under mv-crumbs:<key> on every change and restored on load. Put the user id in the key for a per-user trail. Icons given as Nodes are not stored (names and URLs are).
storagesession | localsessionsessionStorage (one trail per tab session) or localStorage (survives restarts, shared by tabs on load).
limitnumber50Most crumbs kept in total (trail + earlier). Beyond it the oldest unpinned crumb is dropped for good (mv-crumb-remove, reason "limit").
shortcutsstringAlt+[, Alt+]Back and forward shortcuts, page-wide, as two comma-separated combos (Alt, Ctrl, Shift, Meta + a key; bracket keys match by physical key, so Option+[ works on macOS). Ignored while typing in a field. "none" disables them.
labelstringRecently visitedAccessible name of the nav landmark.
localeBCP 47 tagen-USLocale of relative times (“4 minutes ago”).
data-variant"plain"Text crumbs without pill backgrounds, for page headers and docs.
data-size"sm"Compact trail.
data-crumb-id / data-crumb-label / data-crumb-kind / data-crumb-iconon tracked linksOverride the id (default: normalized path, search and hash route), label (default: aria-label, title, then text), kind and icon of the crumb a link drops.
data-crumb="off" / data-crumb-ignoreon links / on containersLinks (or every link inside a container) that never drop a crumb, e.g. section tabs or the main menu.

Properties

NameTypeDescription
crumbsArray<{ key, id, label, href, kind, icon, pinned, current, visitedAt, firstVisitedAt, visits, data, freshness, folded }>Snapshot of the trail in chronological trail order (pinned crumbs are displayed first; freshness 0–1, folded when it sits in the earlier menu). Set it to restore a trail from your server (id, label, href, kind, icon, pinned, visitedAt, data; current: true marks the current one, else the last); no mv-crumb events are emitted, keys are kept so re-setting a modified snapshot animates in place.
currentstring | nullId of the crumb the user is on (read-only).
kindsRecord<string, { label?, icon?, color? }>Per-kind label (shown in the tooltip and descriptions), icon (built-in name, image URL or Node) and color (any CSS color, e.g. var(--mv-info)) that tints the icon while the crumb is fresh. Built-in icons: page, doc, person, building, target, receipt, box, tag, ticket, code, folder, chart, mail, settings, search, columns, calendar, book, with aliases (customer, contact, company, deal, invoice, order, product, issue, repo, report…). Declarative alternative: <template data-kind="deal" data-label="Deal" data-color="…"><svg></svg></template> children.
resolve({ url, href, source, element }) => Partial<crumb> | false | nullHook for tracked visits (links and history): return fields to merge (id, label, kind, icon, data) or false to skip the visit (login pages, search results…).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (keys: label, empty, more ({count}), moreShort, moreLabel, allLabel, panelTitle, panelCount, onTrail, earlier, clear, justNow, visited ({time}), pinned, current, pin ({label}), remove ({label}), pinShort, unpinShort, removeShort, hint, tipHint, announcePin, announceUnpin, announceRemove, announceClear). English defaults.
max / lifetime / revisit / track / trackRoot / persist / storage / limit / shortcuts / label / localereflectedMirror the attributes.

Methods

NameDescription
visit({ id, label, href, kind, icon, data, pinned?, visitedAt? })Records a visit (id defaults to href). Emits the cancelable mv-crumb first. A visit to the crumb just opened from the trail is recognized as a step and refreshes it in place. Returns the crumb snapshot, or null if cancelled or invalid (javascript: and data: hrefs are dropped).
open(id)Opens a crumb as if clicked: emits the cancelable mv-crumb-open, then (not cancelled) marks it current and follows its href with location.assign.
back() / forward()Step to the previous / next crumb along the trail (what Alt+[ / Alt+] do). Return false at either end.
pin(id, force?)Toggles (or sets) the pin. An unpinned crumb starts fading from three quarters of its lifetime, not all at once. Returns the new state.
remove(id)Removes a crumb. Returns false if not found.
clear({ keepPinned = true })Removes every crumb except the current one (and the pinned ones unless keepPinned is false). Returns how many.
refresh()Recomputes freshness now (it is otherwise recomputed about 60 times per lifetime, and when the tab comes back).

Events

NameDescription
mv-crumbCancelable, before a visit is recorded. detail: { crumb: { id, label, href, kind, icon, data } (editable: change the label or kind before it lands), source: "api" | "link" | "history" | "menu", revisit, step }. preventDefault() skips the visit.
mv-crumb-openCancelable, when a crumb is opened from the trail or the earlier menu, or by back() / forward() / open(). detail: { crumb, source: "click" | "keyboard" | "shortcut" | "menu" | "api", direction: "back" | "forward" | null }. Call preventDefault() to route with your SPA router, then call visit() for that id when the view renders (it is recognized as a step). Not cancelled: links navigate natively, open()/shortcuts use location.assign. Modified clicks (new tab) don't emit it.
mv-crumb-pinA crumb was pinned or unpinned. detail: { crumb, pinned }.
mv-crumb-removeA crumb left the trail for good. detail: { crumb, reason: "user" | "api" | "clear" | "limit" }.
mv-crumbs-changeAfter any change (visit, step, pin, removal), batched per task: detail: { crumbs }. Save it to your server for a trail that follows the user across devices.

Content structure

NameDescription
template[data-kind]Optional <template> children defining the icon (and data-label, data-color) of a kind. Nothing else is read from the children.

CSS classes

NameDescription
mv-crumbs-nav / mv-crumbs-listThe labelled nav landmark and its <ol>. The list gets data-tight (and the host too) when even pinned and current crumbs had to shrink, and data-overflow="start | end | both" (soft edges) in the rare case it still scrolls.
mv-crumbs-itemOne crumb (<li>): data-stage 0–3 (3 = fresh), data-pinned, data-current, data-kind, --_f (freshness 0–1) and --_q (the same in 4 steps). Holds .mv-crumbs-link (a, or button without href: .mv-crumbs-icon, .mv-crumbs-label, .mv-crumbs-pinmark) and a visually hidden description.
mv-crumbs-moreThe leading “+N earlier” button (.mv-crumbs-more-text, .mv-crumbs-more-short in tight mode; data-folded when anything is folded, an icon-only “all places” button otherwise).
mv-crumbs-panelAll places popover: -panel-head, .mv-crumbs-group (On the trail / Earlier) with .mv-crumbs-row (.mv-crumbs-row-link, pin, remove), -panel-foot with .mv-crumbs-clear.
mv-crumbs-tipHover / focus card (aria-hidden: its facts are in each crumb's description): -tip-label, -tip-meta, -tip-actions with .mv-crumbs-tip-btn Pin / Remove (pointer only, never focused), -tip-hint instead of the buttons on keyboard focus (pointer-events: none then).

CSS variables

NameDefaultDescription
--mv-crumbs-label-max11remLabel width of a fresh crumb (9rem in data-size="sm").
--mv-crumbs-label-min3.25remLabel width of a crumb about to fold.
--mv-crumbs-fade-floor0.5Opacity of a crumb about to fold (hover and focus bring it back to 1).
--mv-crumbs-pinvar(--mv-accent)Color of the pin mark and of pinned crumbs' edge.
--mv-crumbs-surfacevar(--mv-surface)Plain variant: the background the trail sits on, so the pin / remove overlay blends in.

Accessibility

The trail is a nav landmark named “Recently visited” (label) holding an ordered list, one list item per crumb; the current place carries aria-current="page". The fade is purely visual: every link keeps its full label as text (only clipped with an ellipsis by CSS) and is described by a visually hidden line (“Invoice, visited 4 minutes ago, pinned”) plus the keyboard hint, so screen readers always get the whole name, kind and age. The hover card repeating those facts is aria-hidden and its Pin / Remove buttons are a pointer shortcut that never takes focus. Visits are never announced; only user actions are, politely (“Removed ‘INV-2044’ from the trail.”, pin and clear results). Keyboard: the trail is a single Tab stop (the current crumb by default) with roving focus: Left / Right, Home / End move between the “+N earlier” button and the crumbs, Enter opens, P pins or unpins, Delete or Backspace removes and focus moves to the neighbor; focus shows the card with the key hints. Alt+[ / Alt+] (configurable, ignored while typing in a field) step back and forth along the trail from anywhere on the page. The “+N earlier” button (aria-expanded, a name that includes its visible text) opens a labelled non-modal dialog listing every place, earlier ones first, each as a real link with real Pin (aria-pressed) and Remove buttons in the Tab order, always visible on touch screens: this is the path for screen reader browse mode, where single-letter keys belong to the reader. Focus moves to its first place, Up / Down / Home / End move between places, P and Delete work there too, Escape or an outside click closes it and Escape returns focus to the button. Hovered or focused crumbs return to full opacity; faded crumbs never drop below 50% opacity (--mv-crumbs-fade-floor). Reduced motion (OS or data-motion="reduce"): the fade moves in four discrete opacity and size steps with no transition, and crumbs appear, move and fold without sliding. Forced colors: crumbs use system button colors at full opacity, faded ones get a dashed border, the current one a Highlight border.