Exclusivobeta
Crumbs <mv-crumbs>
Um rastro de onde o usuário realmente esteve, para apps em que as pessoas pulam de um registro para outro (CRM, central de suporte, console de administração, documentação, navegador de código, back office), e não de onde a página fica na hierarquia. Cada visita, vinda de visit({ id, label, href, kind, icon }), de cliques em links da mesma origem (track="links") ou de mudanças no histórico (track="history", Navigation API com popstate/hashchange como alternativa), deixa uma migalha com o ícone do seu tipo no fim de um rastro horizontal compacto; revisitar um lugar move a migalha para a frente em vez de duplicá-la (ou a atualiza no lugar, ou a repete: revisit). As migalhas mais antigas vão esmaecendo, encolhendo e tendo o rótulo cada vez mais cortado à medida que o tempo passa (lifetime) e que as mais recentes se acumulam (max), e as que esmaecem por completo se recolhem em um menu “+12 earlier” que lista cada lugar com o tempo desde a visita, então o rastro continua curto e relevante; ele também nunca rola, porque as migalhas mais antigas se recolhem antes quando falta largura. As migalhas fixadas nunca esmaecem e ficam em primeiro lugar, como as abas fixadas. Abrir uma migalha a partir do rastro é um passo: ela vira a atual no próprio lugar e o rastro nunca se reorganiza sob o ponteiro. Passar o mouse sobre uma migalha abre um pequeno card com o rótulo completo, o tipo, “Visited 4 minutes ago”, Fixar e Remover; o teclado tem foco itinerante, P / Delete e Alt+[ / Alt+] para ir e voltar ao longo do rastro. mv-crumb-open é cancelável para routers de SPA, as visitas podem ser vetadas ou renomeadas por mv-crumb, e o rastro pode persistir por sessão ou por usuário no armazenamento do navegador (opcional) ou no seu servidor, pela propriedade crumbs e por mv-crumbs-change.
| Categoria | Navegação |
|---|---|
| Tipo | Web Component (<mv-crumbs>) |
| Status | beta |
| Kit | Navegação para usuários avançados |
| 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
Instalação
node scripts/add.mjs crumbs --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["crumbs"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): 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.
Uso
Início rápido, a menor marcação que funciona:
<mv-crumbs track="links" persist="app"></mv-crumbs>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis 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 = {
"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>Referência cultural
João e Maria, Irmãos Grimm (1812, fábula). Perdido na floresta, o menino vai jogando migalhas de pão para achar o caminho de volta para casa, mas os pássaros as comem, e o rastro por onde ele passou some atrás dele. Na interface, cada lugar que o usuário realmente visita deixa uma migalha em um rastro de volta pelo seu trabalho; as migalhas mais antigas esmaecem e se recolhem à medida que o tempo passa e novas se acumulam, a menos que o usuário fixe as que valem a pena guardar.
API
Attributes
| Name | Tipo | 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 | Tipo | 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. |
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
| 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 a crumb about to fold (hover and focus bring it back to 1). |
--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 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.