Exclusivebeta
Footprints — <mv-footprints>
Live collaborator presence for long documents, specs, code files and spreadsheets: a slim rail beside the scroll container where each person is a small labeled marker (initials or avatar, in their own color) at the exact place they are reading or editing, trailing a few fading footprints that show which way they have been moving, walking down, walking up, or leaping to another section with a dotted arc. The app feeds positions, typically from a websocket, with move(id, { anchor | top | ratio, mode, note }); anchors are mapped to the rail from cached offsets refreshed only on layout changes, so a burst of remote moves never forces a layout of the document. Your own viewport is a translucent, draggable window, headings become ticks, so the rail doubles as a mini table of contents (current section highlighted), and markers that land close together are spread apart with a tether back to their true position. Hovering or focusing a marker opens a card (name, “Editing · Active now”, section and note, “3 sections below you”); a click jumps to that person, a double-click, F, or the Follow offer shown after a jump keeps you scrolled with them until you scroll yourself. Editing and commenting get a badge, idle people fade (idle-after), then leave the rail (gone-after), and joins and leaves are announced politely and throttled.
| Category | Navigation |
|---|---|
| Type | Web Component (<mv-footprints>) |
| Status | beta |
| Keywords | exclusive, culture, presence, collaboration, multiplayer, realtime, minimap, table-of-contents, scroll, follow-mode, avatars, cursors, document, code-review, websocket, navigation |
When to use
- Several people edit the same long spec, contract or wiki page and each needs to see where the others are working
- A reviewer wants to jump to a colleague's spot in a long code file or sheet, or follow them while they walk through it
- A long document needs a compact section overview and a live presence signal in the same narrow column
- A pair-review or onboarding session needs a follow-the-presenter mode that ends as soon as the viewer scrolls
Avoid when
- Only the reading progress matters, with nobody else in the document → use Scroll Progress instead
- A solo reader needs the article's shape and what they have already read, not other people's positions → use Reading Map instead
- A few flagged items (unread, errors, mentions) must be pointed at from the edges of a scroller or a 2-D canvas → use Edge Tracker instead
Install
node scripts/add.mjs footprints --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["footprints"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/pointer.js, core/position.js, components/footprints/footprints.js, components/footprints/footprints.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="fp-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#fp-demo { display:grid; gap:1.25rem; align-content:start }
#fp-demo .fp-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 }
#fp-demo .fp-bar { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; min-height:3.5rem; padding:.5rem 1rem .5rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#fp-demo .fp-title { display:grid; gap:.125rem; flex:1 1 16rem; min-width:0 }
#fp-demo .fp-title strong { font-size:.9375rem; font-weight:650; letter-spacing:-.01em }
#fp-demo .fp-title span { color:var(--mv-fg-muted); font-size:.75rem }
#fp-demo .fp-stage { display:grid; grid-template-columns:minmax(0,1fr) 3.5rem; height:30rem }
#fp-demo .fp-doc { position:relative; overflow:auto; overscroll-behavior:contain; padding:1.5rem 2.25rem 3rem; scrollbar-width:thin }
#fp-demo .fp-doc h1 { margin:0 0 .375rem; font-size:1.5rem; letter-spacing:-.02em }
#fp-demo .fp-doc .fp-meta { margin:0 0 1.5rem; color:var(--mv-fg-muted); font-size:.8125rem }
#fp-demo .fp-doc h2 { margin:2rem 0 .5rem; font-size:1.125rem; letter-spacing:-.01em }
#fp-demo .fp-doc h3 { margin:1.25rem 0 .375rem; font-size:.9375rem; color:var(--mv-fg) }
#fp-demo .fp-doc p, #fp-demo .fp-doc li { color:var(--mv-fg-muted); font-size:.875rem; line-height:1.65; max-width:42rem }
#fp-demo .fp-doc p { margin:0 0 .75rem }
#fp-demo .fp-doc ul { margin:0 0 .75rem; padding-inline-start:1.25rem }
#fp-demo .fp-doc table { width:100%; max-width:42rem; margin:.5rem 0 1rem; border-collapse:collapse; font-size:.8125rem }
#fp-demo .fp-doc th, #fp-demo .fp-doc td { padding:.5rem .625rem; border-bottom:1px solid var(--mv-border); text-align:start }
#fp-demo .fp-doc th { color:var(--mv-fg); font-weight:600; background:var(--mv-bg-subtle) }
#fp-demo .fp-doc td { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#fp-demo .fp-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) }
#fp-demo .fp-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
#fp-demo .fp-log { flex:1 1 100%; min-height:1rem; margin:0; color:var(--mv-fg-subtle); font:.6875rem/1.4 var(--mv-font-mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
#fp-demo .mv-choice { font-size:.8125rem }
/* Code review variant */
#fp-demo .fp-code-stage { display:grid; grid-template-columns:3.25rem minmax(0,1fr); height:17rem }
#fp-demo .fp-code { overflow:auto; overscroll-behavior:contain; padding:.75rem 0; background:var(--mv-bg-subtle); font:.75rem/1.6 var(--mv-font-mono); scrollbar-width:thin }
#fp-demo .fp-line { display:grid; grid-template-columns:3rem 1fr; white-space:pre; color:var(--mv-fg-muted) }
#fp-demo .fp-line span:first-child { padding-inline-end:1rem; color:var(--mv-fg-subtle); text-align:end; user-select:none }
#fp-demo .fp-line.fp-fn { color:var(--mv-fg); font-weight:600 }
#fp-demo .fp-line.fp-cm { color:var(--mv-fg-subtle); font-style:italic }
@media (max-width:40rem) { #fp-demo .fp-doc { padding:1.25rem 1rem 2rem } }
</style>
<!-- Main: a product spec edited by four people -->
<section class="fp-app" aria-label="Product spec editor">
<header class="fp-bar">
<span class="fp-title">
<strong>Checkout v3 · Product spec</strong>
<span>Edited by 4 people · Last saved Sep 23, 2026, 10:42 AM</span>
</span>
<span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot" data-pulse></span><span id="fp-count">4 here</span></span>
</header>
<div class="fp-stage">
<article class="fp-doc" id="fp-doc" tabindex="0" aria-label="Checkout v3 product spec">
<h1>Checkout v3</h1>
<p class="fp-meta">Owner: Aiko Tanaka · Reviewers: Omar Haddad, Priya Raman, Lena Fischer · Status: In review</p>
<h2 id="fp-summary">Summary</h2>
<p id="fp-p-summary-1">Checkout v3 replaces the three-step checkout with a single adaptive page. Returning customers see a one-tap confirmation; new customers get inline account creation after payment instead of before it.</p>
<p id="fp-p-summary-2">The redesign targets a 12% lift in completed orders on mobile and removes the separate shipping step for digital goods, which make up 38% of order volume.</p>
<h2 id="fp-goals">Goals and non-goals</h2>
<ul>
<li>Reduce median time to purchase from 94 s to under 60 s on mobile.</li>
<li>Support saved payment methods across web and the iOS and Android apps.</li>
<li>Show taxes and fees before the final step in every supported market.</li>
</ul>
<p id="fp-p-goals-1">Out of scope for this release: gift cards, split payments between two cards, and the marketplace seller checkout, which keeps its current flow until Q2 2027.</p>
<h2 id="fp-research">Customer research</h2>
<p id="fp-p-research-1">We ran 18 moderated sessions in Tokyo, Berlin, São Paulo and Chicago. The top abandonment reason was surprise costs at the last step (11 of 18), followed by forced account creation (7 of 18).</p>
<p id="fp-p-research-2">Analytics confirm the pattern: 41% of mobile sessions that reach the shipping step leave before payment, and the drop is twice as steep when the order total changes on that step.</p>
<p id="fp-p-research-3">Participants who could pay with a saved wallet finished in 31 seconds on average, and none of them asked for an order summary before confirming.</p>
<h2 id="fp-pricing">Pricing</h2>
<p id="fp-p-pricing-1">Prices are shown tax-inclusive where required by law and tax-exclusive in the United States and Canada, with a live estimate as soon as a postal code is known.</p>
<h3 id="fp-plans">Plans</h3>
<table>
<thead><tr><th>Plan</th><th>Monthly</th><th>Annual</th><th>Seats</th></tr></thead>
<tbody>
<tr><td>Starter</td><td>$19</td><td>$190</td><td>1</td></tr>
<tr><td>Team</td><td>$49</td><td>$490</td><td>Up to 10</td></tr>
<tr><td>Business</td><td>$129</td><td>$1,290</td><td>Up to 50</td></tr>
</tbody>
</table>
<h3 id="fp-enterprise">Enterprise tier</h3>
<p id="fp-p-enterprise-1">Enterprise customers keep invoiced billing with net-30 terms. The checkout page shows a “Request a quote” path instead of card entry once the seat count passes 50.</p>
<p id="fp-p-enterprise-2">Volume pricing starts at $11 per seat per month and is negotiated per contract; the page never shows a price it cannot honor.</p>
<h3 id="fp-discounts">Discounts</h3>
<p id="fp-p-discounts-1">Promo codes move from a modal to an inline field under the order summary. Only one code applies per order; the best one is kept automatically when a customer tries a second.</p>
<h2 id="fp-payments">Payment methods</h2>
<p id="fp-p-payments-1">Cards, Apple Pay, Google Pay and PayPal at launch; iDEAL, Bancontact and Pix follow in phase 2. Wallet buttons appear first when the device supports them.</p>
<p id="fp-p-payments-2">3-D Secure challenges open in place rather than in a redirect, and a failed challenge keeps the rest of the form intact.</p>
<h2 id="fp-tax">Tax and invoicing</h2>
<p id="fp-p-tax-1">Tax is computed by the existing tax service from the billing address. Business customers can add a VAT or GST number, validated on blur, which removes tax where reverse charge applies.</p>
<p id="fp-p-tax-2">Invoices are generated as PDF within one minute of payment and emailed to the billing contact, with a copy in the account’s billing history.</p>
<h2 id="fp-rollout">Rollout plan</h2>
<h3 id="fp-phase-1">Phase 1 · Mobile web</h3>
<p id="fp-p-phase1-1">Starts October 13, 2026 at 5% of mobile web traffic in the United States, Japan and Germany, ramping to 50% over two weeks if guardrail metrics hold.</p>
<h3 id="fp-phase-2">Phase 2 · Apps and new markets</h3>
<p id="fp-p-phase2-1">The iOS and Android apps adopt the new flow in November, together with local payment methods for the Netherlands, Belgium and Brazil.</p>
<h2 id="fp-metrics">Success metrics</h2>
<p id="fp-p-metrics-1">Primary: completed orders per checkout session. Guardrails: refund rate, payment failure rate, and support contacts tagged “checkout” per 1,000 orders.</p>
<p id="fp-p-metrics-2">We will read results weekly and call the experiment after four weeks or 40,000 sessions per arm, whichever comes first.</p>
<h2 id="fp-risks">Risks</h2>
<p id="fp-p-risks-1">Showing tax earlier may lower the add-to-cart rate in tax-inclusive markets. A holdout group of 10% keeps the old checkout during the whole ramp.</p>
<h2 id="fp-questions">Open questions</h2>
<p id="fp-p-questions-1">Should guest checkout remember the email for a returning device? Legal review is scheduled for September 30, 2026.</p>
<p id="fp-p-questions-2">Do we keep the order-notes field? Only 0.4% of orders use it, but most of those are high-value business orders.</p>
</article>
<mv-footprints id="fp-rail" scroller="#fp-doc" headings="h2, h3" idle-after="45s" label="Spec map and collaborators"></mv-footprints>
</div>
<div class="fp-controls">
<span class="fp-actions">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="fp-live" checked> Live updates</label>
</span>
<span class="fp-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fp-follow">Follow Omar</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fp-leap">Priya jumps</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="fp-join">Mateo joins</button>
</span>
<p class="fp-log" id="fp-log" aria-live="off">Hover a marker for details, click to jump, double-click to follow.</p>
</div>
</section>
<!-- Variant: a code review, rail on the start side, functions as ticks -->
<section class="fp-app" aria-label="Code review">
<header class="fp-bar">
<span class="fp-title">
<strong>payments/checkout.ts</strong>
<span>Pull request #482 · Review in progress · rail on the start side, functions as sections</span>
</span>
</header>
<div class="fp-code-stage">
<mv-footprints id="fp-code-rail" scroller="#fp-code" headings=".fp-fn" side="start" label="File map and reviewers"></mv-footprints>
<div class="fp-code" id="fp-code" tabindex="0" role="region" aria-label="checkout.ts source"></div>
</div>
</section>
<script type="module">
const rail = document.getElementById("fp-rail");
const log = document.getElementById("fp-log");
const count = document.getElementById("fp-count");
const live = document.getElementById("fp-live");
const joinBtn = document.getElementById("fp-join");
const followBtn = document.getElementById("fp-follow");
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const say = (text) => { log.textContent = `${time()} · ${text}`; };
// Simulated presence feed (what a websocket would deliver).
const doc = document.getElementById("fp-doc");
const paragraphs = [...doc.querySelectorAll("p[id]")].map((p) => p.id);
const now = Date.now();
rail.people = [
{ id: "aiko", name: "Aiko Tanaka", mode: "editing", anchor: "fp-p-enterprise-1", note: "Rewriting volume pricing", at: now },
{ id: "omar", name: "Omar Haddad", mode: "viewing", anchor: "fp-p-research-1", at: now },
{ id: "priya", name: "Priya Raman", mode: "commenting", anchor: "fp-p-phase1-1", note: "Comment on ramp dates", at: now },
{ id: "lena", name: "Lena Fischer", mode: "viewing", anchor: "fp-p-summary-2", at: now - 3 * 60_000 },
];
let omar = paragraphs.indexOf("fp-p-research-1");
let priyaAt = "fp-p-phase1-1";
let beat = 0;
const step = () => {
if (!live.checked) return;
beat++;
// Omar reads steadily down the spec, then starts over from the top.
omar = omar + 1 < paragraphs.length ? omar + 1 : 0;
rail.move("omar", { anchor: paragraphs[omar], mode: "viewing" });
// Aiko keeps editing the enterprise section.
rail.move("aiko", { anchor: beat % 4 < 2 ? "fp-p-enterprise-1" : "fp-p-enterprise-2", mode: "editing", note: "Rewriting volume pricing" });
if (beat % 6 === 0) leap();
if (rail.people.some((p) => p.id === "mateo")) rail.move("mateo", { anchor: paragraphs[(beat + 3) % paragraphs.length] });
};
const leap = () => {
const targets = ["fp-p-payments-1", "fp-p-phase1-1", "fp-p-goals-1", "fp-p-metrics-2", "fp-p-tax-1"];
const others = targets.filter((t) => t !== priyaAt);
priyaAt = others[Math.floor(Math.random() * others.length)];
rail.move("priya", { anchor: priyaAt, mode: "commenting", note: "Reviewing comments" });
};
const timer = setInterval(step, 1600);
rail.addEventListener("mv-join", (e) => say(`${e.detail.person.name} joined`));
rail.addEventListener("mv-leave", (e) => say(`${e.detail.person.name} left (${e.detail.reason})`));
rail.addEventListener("mv-status", (e) => say(`${e.detail.person.name} is ${e.detail.status}`));
rail.addEventListener("mv-jump", (e) => say(e.detail.kind === "person" ? `Jumped to ${e.detail.person.name} in ${e.detail.person.section}` : `Jumped to ${e.detail.label}`));
rail.addEventListener("mv-follow", (e) => { say(`Following ${e.detail.person.name}`); followBtn.textContent = "Stop following"; });
rail.addEventListener("mv-unfollow", (e) => { say(`Stopped following (${e.detail.reason})`); followBtn.textContent = "Follow Omar"; });
const recount = () => { count.textContent = `${rail.people.length} here`; };
rail.addEventListener("mv-join", recount);
rail.addEventListener("mv-leave", recount);
followBtn.addEventListener("click", () => (rail.following ? rail.unfollow() : rail.follow("omar")));
document.getElementById("fp-leap").addEventListener("click", leap);
joinBtn.addEventListener("click", () => {
if (rail.people.some((p) => p.id === "mateo")) { rail.leave("mateo"); joinBtn.textContent = "Mateo joins"; }
else { rail.join("mateo", { name: "Mateo Rossi", mode: "viewing", anchor: "fp-p-tax-2" }); joinBtn.textContent = "Mateo leaves"; }
});
// Code review variant: build the file (text only), then two reviewers.
const code = document.getElementById("fp-code");
const src = [
["cm", "// Checkout orchestration: quote, pay, confirm."],
["", "import { taxFor } from \"./tax\";"],
["", "import { charge, confirm3ds } from \"./gateway\";"],
["", ""],
["fn", "export async function quote(cart, address) {"],
["", " const subtotal = cart.items.reduce((s, i) => s + i.price * i.qty, 0);"],
["", " const tax = await taxFor(address, subtotal);"],
["", " const fees = cart.express ? 9.5 : 0;"],
["", " return { subtotal, tax, fees, total: subtotal + tax + fees };"],
["", "}"],
["", ""],
["fn", "export function applyPromo(quote, codes) {"],
["cm", " // Only one code per order: keep the best one."],
["", " const best = codes.map((c) => c.value(quote)).sort((a, b) => b - a)[0] ?? 0;"],
["", " return { ...quote, discount: best, total: quote.total - best };"],
["", "}"],
["", ""],
["fn", "export async function pay(order, method) {"],
["", " const intent = await charge(order.total, method);"],
["", " if (intent.status === \"requires_action\") {"],
["", " const ok = await confirm3ds(intent);"],
["", " if (!ok) return { status: \"failed\", keepForm: true };"],
["", " }"],
["", " return { status: \"paid\", id: intent.id };"],
["", "}"],
["", ""],
["fn", "export async function invoice(order, customer) {"],
["", " const pdf = await render(\"invoice\", { order, customer });"],
["", " await mail(customer.billingEmail, pdf);"],
["", " return pdf.url;"],
["", "}"],
["", ""],
["fn", "export function receipt(order) {"],
["", " return {"],
["", " lines: order.items.map((i) => `${i.qty} × ${i.name}`),"],
["", " total: formatUsd(order.total),"],
["", " };"],
["", "}"],
];
src.forEach(([kind, text], i) => {
const line = document.createElement("div");
line.className = `fp-line${kind ? ` fp-${kind}` : ""}`;
line.id = `fp-l${i + 1}`;
if (kind === "fn") line.dataset.footprintsLabel = text.replace(/^export (async )?function (\w+).*/, "$2()");
const n = document.createElement("span");
n.textContent = String(i + 1);
const t = document.createElement("span");
t.textContent = text || " ";
line.append(n, t);
code.append(line);
});
const codeRail = document.getElementById("fp-code-rail");
codeRail.people = [
{ id: "kwame", name: "Kwame Boateng", mode: "commenting", anchor: "fp-l21", note: "Line 21: retry on 3-D Secure timeout?" },
{ id: "sofia", name: "Sofia Nguyen", mode: "viewing", anchor: "fp-l6" },
];
let line = 6;
const codeTimer = setInterval(() => {
if (!live.checked) return;
line = line >= src.length - 2 ? 2 : line + 2;
codeRail.move("sofia", { anchor: `fp-l${line}` });
}, 1400);
// The demo can be replayed: stop the fake feeds when it leaves the page.
const mo = new MutationObserver(() => { if (!rail.isConnected) { clearInterval(timer); clearInterval(codeTimer); mo.disconnect(); } });
mo.observe(document.body, { childList: true, subtree: true });
</script>
</div>Cultural reference
Harry Potter and the Prisoner of Azkaban — J. K. Rowling (1999, book). An enchanted map of a castle shows every person inside as a labeled trail of footprints moving in real time, so its holder knows where everyone is and where they are heading. In the UI, a rail beside a long shared document shows each collaborator as a labeled marker at their live position with fading footprints that reveal their direction, and lets you jump to them or follow them.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
scroller | CSS selector | The scrolling container the rail describes, looked up in the element's root (retried for a few seconds if rendered later). Omitted: the page itself; the rail then defaults to position: sticky; top: 0; height: 100dvh. Place the element next to the scroller (a grid column), not inside it. | |
headings | CSS selector | h2, h3 | Elements inside the scroller that become section ticks and name the sections in labels. Levels come from h1–h6, aria-level or data-footprints-level; the label from data-footprints-label or the text. |
side | "end" | "start" | end | Which side of the content the rail sits on: ticks face the content, the card and the Follow pill open toward it. Mirrors automatically in right-to-left layouts. |
idle-after | time | 60s | Without a move or ping for this long, a person turns idle (faded, desaturated, “Idle for 3 min”). Accepts ms, s, m, h; "none" disables. |
gone-after | time | 5m | Without activity for this long, a person leaves the rail (mv-leave with reason "timeout"). "none" keeps everyone until leave(). |
trail | number | 6 | Footprints drawn per move (0 disables the trail). Their fade time is --mv-footprints-trail-life. |
offset | number (px) | 0 | Height of a sticky header inside the scroller: section jumps land below it and the current section is judged from there. |
focus-line | number (0..1) | 0.3 | Where in your viewport a person lands after a jump and while you follow them (0 = top). |
focus-on-jump | "keyboard" | "always" | "never" | keyboard | Whether a jump also moves focus to the target (the person's anchor or the heading), like a table-of-contents link. By default only keyboard activations do. |
announce | "polite" | "off" | polite | Live announcements of joins and leaves (coalesced, at most one every 4 s) and of Follow starting or stopping. |
label | string | Document map | Accessible name of the rail. |
Properties
| Name | Type | Description |
|---|---|---|
people | Array<{ id, name, color?, avatar?, initials?, mode?, note?, at?, anchor? | top? | ratio? }> | Read: present people, top of the document first, as snapshots { id, name, color, mode, note, status, top, section, lastActive }. Write: replaces the roster silently (no announcement), handy for the initial state from the server. |
following | string | null | Id of the person being followed (read-only; use follow() / unfollow()). |
sections | Array<{ label, level, top, element }> | Sections read from the headings, with their cached offsets (read-only). |
scrollerElement | Element | null | Set the scroller as an element instead of a selector; reads the resolved scroller (null for the page). |
strings | object | Every visible and announced text (sentence template, “3 sections below you”, hints, Follow labels…), merged over the en-US defaults. |
Methods
| Name | Description |
|---|---|
move(id, { anchor | anchorId | top | ratio, offset?, mode?, note?, at?, name? }) | Someone moved. anchor: an element, an id or a selector inside the scroller (offset adds px within it); top: px from the top of the content; ratio: 0..1 of the content height. mode: "viewing" | "editing" | "commenting" or your own; note: a detail shown in the card (“Enterprise tier”); at: activity timestamp (default now). Unknown ids join automatically. |
join(id, info) | Adds or updates someone ({ name, color, avatar, initials } plus any move() field) and announces the arrival. Returns the snapshot. |
leave(id) | Removes someone (fades out, announced). |
ping(id, at?) | Activity without movement: keeps someone from turning idle. |
jumpTo(target) | Scrolls to a person (id) or a section (heading element or index), like a click. Returns false if canceled or unknown. |
follow(id) / unfollow() | Keeps the view on someone as they move; any scroll by the user (wheel, touch, keys, scrollbar, rail) stops it. |
refresh() | Re-reads headings and anchor offsets after a layout change the observers cannot see (e.g. content inside a canvas). |
Events
| Name | Description |
|---|---|
mv-join | Someone appeared. detail: { id, person }. |
mv-leave | Someone left. detail: { id, person, reason: "left" | "timeout" | "sync" }. |
mv-status | Someone turned idle or active again. detail: { id, status, person }. |
mv-jump | Before scrolling to a person or section. detail: { kind: "person" | "section", id?, person?, heading?, label?, top, source: "pointer" | "keyboard" | "api" }. Cancelable: preventDefault() and scroll yourself (virtualized lists, lazy sections). |
mv-follow | Before following someone. detail: { id, person }. Cancelable. |
mv-unfollow | Follow ended. detail: { id, person, reason: "user-scroll" | "api" | "left" | "replaced" | "disconnect" }. |
CSS classes
| Name | Description |
|---|---|
mv-footprints-rail | The rail (role=group): viewport window, section ticks, footprints layer and people list. |
mv-footprints-window | Your viewport; draggable. data-following while you follow someone (tinted with their color). |
mv-footprints-tick / -tick-line | Section buttons in an <ol>; data-depth 0–2, data-current and aria-current="location" on your section. |
mv-footprints-person | <li> per person, --_c = their color; data-mode, data-state="active | idle", data-following. |
mv-footprints-marker / -avatar / -badge | The jump button with initials or avatar, and the editing / commenting badge. |
mv-footprints-pin / -tether | Exact position in the lane, and the line to the marker when it had to be spread apart. |
mv-footprints-step / -leap | Footprints (data-foot, data-dir) and the dotted arc of a leap; removed when their fade ends. |
mv-footprints-card | Hover / focus card (popover, aria-hidden: the marker's accessible name carries the same text). |
mv-footprints-follow | Pill toward the content: data-state="offer" after a jump, "following" with a Stop button. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-footprints-width | 3.5rem | Rail width. |
--mv-footprints-marker-size | 1.5rem | Marker diameter (spacing between spread markers follows). |
--mv-footprints-color-1 … -8 | oklch(0.55 0.17 262) … | Palette given to people without a color (each present person gets a distinct one). |
--mv-footprints-trail-life | 6s | How long a footprint takes to fade. |
--mv-footprints-background | bg-subtle, 80% | Rail background. |
Accessibility
The rail is a labeled group holding two real lists, each a single Tab stop with roving focus (Up/Down, Home/End). The people list is in document order and every marker is a button whose accessible name is a full sentence kept up to date as people and you move: “Aiko Tanaka is editing Pricing (Enterprise tier), 3 sections below you”, “… on your screen”, “…, following”; its description says “Press Enter to jump there, F to follow” (aria-keyshortcuts="F"). The sections list is a table of contents: each tick is a button named after its heading, and your current section has aria-current="location". Keyboard activation moves focus to the target (the person's anchor or the heading, with a temporary tabindex=-1) like a TOC link; focus-on-jump changes that. Escape stops Follow or closes the card, and a focused marker that leaves hands focus to its neighbor. The hover/focus card duplicates the name visually, so it is aria-hidden and never takes focus; the Follow pill's button carries “Follow Aiko Tanaka” / “Stop following Aiko Tanaka”. A polite live region announces joins and leaves only (coalesced over 1.5 s, at most one message every 4 s, someone who joins and leaves inside that window is not mentioned) plus Follow starting and stopping, since those are the user's own actions; moves, idling and trails are never announced. Color is never the only cue: initials or avatars name each marker, editing and commenting have distinct icons, idle people are faded and hollow. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no footprints or leap arcs are drawn, markers and the Follow scroll jump instead of gliding, and the editing ring stops breathing. Forced colors: system colors for the rail, window, ticks and markers, Highlight for your window and the followed marker. Performance: moves only store a spec; a single frame loop wakes on moves, scrolls and layout changes, reads offsets only after a resize or mutation, writes transforms, and stops when idle; everything is torn down when the element is removed.