エクスクルーシブ
Crumbs <mv-crumbs>
階層内でのページの位置ではなく、ユーザーが実際にたどった場所の履歴です。CRM、サポートデスク、管理コンソール、ドキュメント、コードブラウザー、バックオフィスなど、レコード間を行き来するアプリ向けです。
文化的リファレンス
ヘンゼルとグレーテル、グリム兄弟(1812年、童話)。森で迷った男の子は帰り道を見つけるためにパンくずを落としていきますが、鳥がそれを食べてしまうので、通ってきた道の跡は背後で消えていきます。UI では、ユーザーが実際に訪れた場所ごとにパンくずが落とされ、作業をさかのぼる軌跡になります。古いパンくずは時間の経過や新しいパンくずの増加とともに薄れて折りたたまれますが、残したいものはユーザーがピン留めできます。
仕組み
ユーザーが実際に訪れた場所の軌跡。レコード間を行き来するアプリ(CRM、サポートデスク、管理コンソール、ドキュメント、コードブラウザー、バックオフィス)向けで、ページが階層のどこにあるかを示すものではありません。visit({ id, label, href, kind, icon })、同一オリジンのリンククリック(track="links")、履歴の変化(track="history"。Navigation API を使用し、popstate/hashchange にフォールバック)による各訪問が、コンパクトな横並びの軌跡の末尾に種類アイコン付きのパンくずを追加します。再訪問するとパンくずは複製されずに前へ移動します(その場で更新する、または繰り返すことも可能:revisit)。古いパンくずは時間の経過(lifetime)や新しいパンくずの蓄積(max)に応じて徐々に薄く小さくなり、ラベルも少しずつ短く削られていきます。完全に薄れたものは、各場所を訪問からの経過時間とともに一覧表示する「+12 earlier」メニューにまとめられるので、軌跡は短く的確なまま保たれます。幅が足りなくなると最も古いパンくずから早めに折りたたまれるため、スクロールすることもありません。ピン留めしたパンくずは薄れることがなく、ピン留めしたタブのように先頭に並びます。軌跡からパンくずを開くことは 1 つのステップとして扱われ、そのパンくずがその場で現在地になり、ポインターの下で軌跡が並び替わることはありません。パンくずにホバーすると、完全なラベル、種類、「Visited 4 minutes ago」、Pin、Remove を含む小さなカードが開きます。キーボードではロービングフォーカス、P / Delete、そして Alt+[ / Alt+] で軌跡を前後に移動できます。mv-crumb-open は SPA ルーター向けにキャンセル可能で、訪問は mv-crumb で拒否したりラベルを変更したりできます。軌跡はブラウザーストレージ(オプトイン)でセッションごとまたはユーザーごとに、あるいは crumbs プロパティと mv-crumbs-change を通じてサーバー側で永続化できます。
| カテゴリー | ナビゲーション |
|---|---|
| タイプ | Web Component(<mv-crumbs>) |
| ステータス | 安定版 |
| キット | パワーユーザー向けナビゲーション |
| Keywords | exclusive, culture, breadcrumb, history, recent, recently-viewed, visited, trail, navigation, back-forward, pin, crm, support-desk, admin, spa-router, persistence, fade, keyboard |
When to use
- People jump between related records (customer, deal, invoice, another customer) and lose track of how they got here
- Support agents or admins juggle several tickets, accounts or orders and need one-click returns to the ones they just touched
- A docs site or code browser should offer a short, self-cleaning list of pages actually read in this session
- The browser back button is not enough because the app mixes panels, modals and routes, and users need to see the path
Avoid when
- The trail must show where the page sits in the site structure (Home / Settings / Billing) → use Breadcrumb instead
- Users need to catch up on what changed while they were away, not return to places they visited → use While Away instead
- People need to jump anywhere by typing, including places they have never visited → use Command instead
インストール
node scripts/add.mjs crumbs --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["crumbs"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):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。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-crumbs track="links" persist="app"></mv-crumbs>基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<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 = {
"brightmoor": { kind: "customer", title: "Brightmoor Logistics", sub: "Enterprise · Columbus, 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: "Brightmoor Logistics · issued Aug 29, 2026", badge: "Paid", fields: [["Amount", "$46,000.00"], ["Due", "Sep 28, 2026"], ["Method", "Wire"]], rel: ["brightmoor"] },
"tk-4471": { kind: "ticket", title: "#4471 SSO login loop", sub: "Brightmoor Logistics · priority High", badge: "Open", fields: [["Assignee", "Mateo Silva"], ["Opened", "Sep 22, 2026"], ["SLA", "3 h left"]], rel: ["brightmoor", "c-okafor"] },
"quellan": { kind: "deal", title: "Quellan expansion", sub: "Quellan · Proposal stage", badge: "Proposal", fields: [["Value", "$96,500"], ["Close", "Nov 14, 2026"], ["Probability", "40%"]], rel: ["solace"] },
"solace": { kind: "customer", title: "Solace Health", sub: "Mid-market · Toronto, CA", badge: "Trial", fields: [["ARR", "$0"], ["Owner", "Priya Nair"], ["Trial ends", "Oct 9, 2026"]], rel: ["quellan", "inv-2044"] },
"inv-2044": { kind: "invoice", title: "INV-2044", sub: "Solace Health · issued Sep 18, 2026", badge: "Overdue", fields: [["Amount", "$8,250.00"], ["Due", "Sep 21, 2026"], ["Method", "Card"]], rel: ["solace"] },
"tarnwick": { kind: "customer", title: "Tarnwick 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: "Tarnwick renewal FY27", sub: "Tarnwick Traders · Negotiation", badge: "Negotiation", fields: [["Value", "$224,000"], ["Close", "Oct 31, 2026"], ["Probability", "75%"]], rel: ["tarnwick", "c-tanaka", "inv-2048"] },
"c-tanaka": { kind: "contact", title: "Aiko Tanaka", sub: "VP Operations · Tarnwick Traders", badge: "Champion", fields: [["Email", "[email protected]"], ["Phone", "+1 206 555 0148"], ["Time zone", "PT"]], rel: ["tarnwick", "nw-renewal"] },
"inv-2048": { kind: "invoice", title: "INV-2048", sub: "Tarnwick Traders · draft", badge: "Draft", fields: [["Amount", "$17,400.00"], ["Due", "Oct 15, 2026"], ["Lines", "3"]], rel: ["tarnwick", "nw-renewal", "c-tanaka", "tk-4502"] },
"tk-4502": { kind: "ticket", title: "#4502 Export to CSV times out", sub: "Tarnwick Traders · priority Normal", badge: "Pending", fields: [["Assignee", "Mateo Silva"], ["Opened", "Sep 23, 2026"], ["SLA", "1 d left"]], rel: ["tarnwick", "inv-2048"] },
"c-okafor": { kind: "contact", title: "Chidi Okafor", sub: "IT Director · Brightmoor Logistics", badge: "Admin", fields: [["Email", "[email protected]"], ["Phone", "+1 312 555 0190"], ["Time zone", "CT"]], rel: ["brightmoor", "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 = [
["brightmoor", 52], ["inv-2031", 47], ["tk-4471", 40, true], ["quellan", 31], ["solace", 23],
["inv-2044", 16], ["tarnwick", 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>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
max | number | 6 | How 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. |
lifetime | time ("30m", "2h", "90s", ms number) | 30m | Time 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. |
revisit | move | stay | repeat | move | What 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-root | CSS selector | Only links inside this element are tracked (default: the whole document). | |
persist | string | Storage 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). | |
storage | session | local | session | sessionStorage (one trail per tab session) or localStorage (survives restarts, shared by tabs on load). |
limit | number | 50 | Most crumbs kept in total (trail + earlier). Beyond it the oldest unpinned crumb is dropped for good (mv-crumb-remove, reason "limit"). |
shortcuts | string | Alt+[, 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. |
label | string | Recently visited | Accessible name of the nav landmark. |
locale | BCP 47 tag | en-US | Locale 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-icon | on tracked links | Override 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-ignore | on links / on containers | Links (or every link inside a container) that never drop a crumb, e.g. section tabs or the main menu. |
Properties
| Name | タイプ | Description |
|---|---|---|
crumbs | Array<{ 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. |
current | string | null | Id of the crumb the user is on (read-only). |
kinds | Record<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 | null | Hook 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…). |
strings | Partial<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 / locale | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
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. |
removeItem(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
| Name | Description |
|---|---|
mv-crumb | Cancelable, 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-open | Cancelable, 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-pin | A crumb was pinned or unpinned. detail: { crumb, pinned }. |
mv-crumb-remove | A crumb left the trail for good. detail: { crumb, reason: "user" | "api" | "clear" | "limit" }. |
mv-crumbs-change | After 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
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-crumbs-nav / mv-crumbs-list | The 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-item | One 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-more | The 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-panel | All 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-tip | Hover / 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
| Name | Default | Description |
|---|---|---|
--mv-crumbs-label-max | 11rem | Label width of a fresh crumb (9rem in data-size="sm"). |
--mv-crumbs-label-min | 3.25rem | Label width of a crumb about to fold. |
--mv-crumbs-fade-floor | 0.5 | Opacity of the pill, icon and connector of a crumb about to fold (hover and focus bring it back to 1). The label only dims to --mv-fg-subtle, so text stays readable. |
--mv-crumbs-pin | var(--mv-accent) | Color of the pin mark and of pinned crumbs' edge. |
--mv-crumbs-surface | var(--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 strength. The fade dims the pill, icon and connector (never below 50%, --mv-crumbs-fade-floor), while the label text only dims to the subtle text color, so it keeps a 4.5:1 contrast in both themes. 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.