Exclusivebeta
Thread Back — <mv-thread-back>
A return thread for deep drill-downs that leave a task: tie it at an origin (a checkout, a report, an onboarding step) with tie({ label, href, state, restore }) or a data-thread-tie link, and every navigation after that unwinds it one step. A slim line anchored to the page edge grows a bead per step, and a tag hanging from its knot (“Back to Checkout · 3 steps”) brings the user straight back to the origin in one action, then restores the scroll position, the focused control and whatever state the app saved at tie time (a form draft, filters). The path is honest: walking back with the browser Back button or reaching the origin by any other link rewinds or closes the thread, loops never inflate the count, and it expires after an idle timeout. Threads nest (a thread inside a thread shows as layered lines, up to 3) and a path menu lists every step with a Return action on each knot. Works in SPAs (Navigation API tracking, or step() from your router, and a cancelable mv-thread-return so your router navigates) and in multi-page sites (per-tab sessionStorage, an origin link carrying ?mv-thread=<id> that is cleaned on arrival), with an optional keyboard shortcut and an Undo after dismissing.
| Category | Navigation |
|---|---|
| Type | Web Component (<mv-thread-back>) |
| Status | beta |
| Also installs | button, kbd |
| Keywords | exclusive, culture, navigation, return, back, drill-down, deep-link, return-to, origin, checkout, onboarding, wayfinding, history, restore-state, scroll-restoration, spa, mpa, session-storage, keyboard-shortcut |
When to use
- A checkout or signup sends users through several pages to fix one detail and they must land back exactly where they were
- Analysts drill from a report cell into lists, records and related records and need one action to get back to the report
- An onboarding or setup step links out to settings pages and must resume with its draft and scroll position intact
- Support or ops staff follow links deep into related records while one case stays their home base
Avoid when
- The trail must show where the page sits in the site structure (Home / Settings / Billing) → use Breadcrumb instead
- Users hop between many records and want to reopen any place they visited recently → use Crumbs instead
- A branching decision flow where users explore an answer and flip back to a held step → use Finger Hold instead
Install
node scripts/add.mjs thread-back --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["thread-back"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/thread-back/thread-back.js, components/thread-back/thread-back.css, components/button/button.css, components/kbd/kbd.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="tb-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#tb-demo { display:grid; gap:1.25rem; align-content:start }
#tb-demo .tb-app { display:flex; flex-direction:column; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg); box-shadow:var(--mv-shadow-md); overflow:hidden }
#tb-demo .tb-chrome { display:flex; align-items:center; gap:.75rem; height:2.25rem; padding:0 .875rem; border-bottom:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#tb-demo .tb-dots { display:flex; gap:.375rem }
#tb-demo .tb-dots i { width:.625rem; height:.625rem; border-radius:50%; background:var(--mv-border-strong) }
#tb-demo .tb-url { flex:1; max-width:26rem; margin-inline:auto; padding:.25rem .75rem; border-radius:var(--mv-radius-full); background:var(--mv-bg); color:var(--mv-fg-muted); font:.75rem/1.2 var(--mv-font-mono); text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
#tb-demo .tb-bar { display:flex; align-items:center; gap:1rem; height:3.25rem; padding:0 1.125rem; border-bottom:1px solid var(--mv-border); background:var(--mv-surface) }
#tb-demo .tb-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
#tb-demo .tb-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-fg); color:var(--mv-bg) }
#tb-demo .tb-logo svg { width:1rem; height:1rem }
#tb-demo .tb-nav { display:flex; gap:.25rem; margin:0 0 0 .5rem; padding:0; list-style:none }
#tb-demo .tb-nav span { display:block; padding:.375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500 }
#tb-demo .tb-end { display:flex; align-items:center; gap:.75rem; margin-inline-start:auto; color:var(--mv-fg-muted); font-size:.8125rem }
#tb-demo .tb-me { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.75rem; font-weight:600 }
#tb-demo .tb-view { flex:1; min-height:0; overflow:auto; padding:1.25rem 1.5rem 1.5rem; outline:none; scroll-behavior:auto }
#tb-demo #tb-shop .tb-screen { height:30rem }
#tb-demo #tb-report .tb-screen { height:24rem }
#tb-demo .tb-screen { display:flex; flex-direction:column; min-height:0 }
#tb-demo .tb-page { display:grid; gap:1rem; max-width:40rem }
#tb-demo .tb-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap }
#tb-demo .tb-head h3 { margin:0; font-size:1.25rem; letter-spacing:-.015em }
#tb-demo .tb-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#tb-demo .tb-card { display:grid; gap:.75rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#tb-demo .tb-row { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem }
#tb-demo .tb-k { display:block; margin-bottom:.25rem; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#tb-demo .tb-v { margin:0; font-size:.875rem; line-height:1.5 }
#tb-demo .tb-v small { color:var(--mv-fg-muted); font-size:.8125rem }
#tb-demo .tb-link { flex:none; color:var(--mv-accent-fg); font-size:.8125rem; font-weight:600; text-decoration:none; border-radius:var(--mv-radius-sm) }
#tb-demo .tb-link:hover { text-decoration:underline; text-underline-offset:3px }
#tb-demo .tb-link:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#tb-demo .tb-sum { display:grid; gap:.375rem; font-size:.875rem }
#tb-demo .tb-sum div { display:flex; justify-content:space-between; color:var(--mv-fg-muted) }
#tb-demo .tb-sum div:last-of-type { padding-top:.5rem; border-top:1px solid var(--mv-border); color:var(--mv-fg); font-weight:650 }
#tb-demo .tb-field { display:grid; gap:.375rem; font-size:.8125rem; font-weight:500 }
#tb-demo .tb-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:.75rem }
#tb-demo .tb-list { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
#tb-demo .tb-item { display:flex; align-items:center; gap:.75rem; padding:.75rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface); font-size:.875rem }
#tb-demo .tb-item b { font-weight:600 }
#tb-demo .tb-item .tb-grow { flex:1; min-width:0 }
#tb-demo .tb-item small { display:block; color:var(--mv-fg-muted); font-size:.75rem }
#tb-demo .tb-card-ico { display:grid; place-items:center; width:2.5rem; height:1.75rem; border-radius:var(--mv-radius-sm); background:var(--mv-bg-muted); color:var(--mv-fg-muted); font:600 .625rem/1 var(--mv-font-sans); letter-spacing:.04em }
#tb-demo .tb-actions { display:flex; gap:.5rem; flex-wrap:wrap; align-items:center }
#tb-demo .tb-note { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#tb-demo .tb-restored { display:none }
#tb-demo .tb-restored[data-on] { display:inline-flex }
#tb-demo table.tb-table { width:100%; border-collapse:separate; border-spacing:0; font-size:.8125rem; font-variant-numeric:tabular-nums }
#tb-demo .tb-table th, #tb-demo .tb-table td { padding:.5rem .625rem; border-bottom:1px solid var(--mv-border); text-align:right; white-space:nowrap }
#tb-demo .tb-table th:first-child, #tb-demo .tb-table td:first-child { text-align:left }
#tb-demo .tb-table thead th { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#tb-demo .tb-table a { color:var(--mv-accent-fg); font-weight:600; text-decoration:none; border-radius:var(--mv-radius-sm) }
#tb-demo .tb-table a:hover { text-decoration:underline; text-underline-offset:3px }
#tb-demo .tb-table a:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#tb-demo .tb-foot { 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) }
#tb-demo .tb-foot p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; flex:1 1 18rem }
#tb-demo .tb-log { margin:0; min-height:1.1rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1 1 100% }
@media (max-width:40rem) {
#tb-demo .tb-nav { display:none }
#tb-demo .tb-view { padding:1rem }
#tb-demo .tb-grid2 { grid-template-columns:1fr }
}
</style>
<!-- 1. Store checkout: fixing one detail takes 3 pages; one click brings the user back, draft and scroll intact -->
<section class="tb-app" id="tb-shop" aria-label="Online store (demo)">
<div class="tb-chrome" aria-hidden="true"><span class="tb-dots"><i></i><i></i><i></i></span><span class="tb-url" id="tb-shop-url">northwind.shop/checkout</span></div>
<div class="tb-screen">
<header class="tb-bar">
<span class="tb-brand"><span class="tb-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9z"/><path d="m12 12 8-4.5M12 12v9M12 12 4 7.5"/></svg></span>Northwind Outfitters</span>
<ul class="tb-nav" aria-hidden="true"><li><span>Shop</span></li><li><span>Orders</span></li><li><span>Account</span></li></ul>
<span class="tb-end"><span>Cart (3)</span><span class="tb-me" aria-hidden="true">MC</span></span>
</header>
<mv-thread-back id="tb-shop-thread" placement="inline" track="manual" storage="none" scope="#tb-shop" scroller="#tb-shop-view" shortcut="alt+shift+b"></mv-thread-back>
<main class="tb-view" id="tb-shop-view" tabindex="-1" aria-label="Store page"></main>
</div>
<div class="tb-foot">
<p>Here the user left Checkout to add a card. <b>Back to Checkout</b> (or <kbd class="mv-kbd-group"><kbd class="mv-kbd">Alt</kbd><kbd class="mv-kbd">Shift</kbd><kbd class="mv-kbd">B</kbd></kbd>) returns in one step with the delivery note and scroll position restored.</p>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="tb-shop-replay">Replay</button>
<div class="tb-log" id="tb-shop-log" aria-live="polite"></div>
</div>
<template data-route="/checkout" data-title="Checkout">
<div class="tb-page">
<div class="tb-head"><div><h3>Checkout</h3><p>3 items · Order total $412.00</p></div><span class="mv-badge tb-restored" data-variant="success" data-shape="pill">Draft restored</span></div>
<div class="tb-card">
<div class="tb-row"><div><span class="tb-k">Ship to</span><p class="tb-v">Maya Chen<br><small>1200 Market St, Apt 5B, San Francisco, CA 94102</small></p></div><a class="tb-link" id="tb-change-address" href="#/account/addresses" data-thread-tie="Checkout">Change</a></div>
</div>
<div class="tb-card">
<div class="tb-row"><div><span class="tb-k">Delivery</span><p class="tb-v">Standard, free<br><small>Arrives Tuesday, October 6</small></p></div></div>
</div>
<div class="tb-card">
<div class="tb-row"><div><span class="tb-k">Payment</span><p class="tb-v">Visa ending in 4242<br><small>Expires 08/2027</small></p></div><a class="tb-link" id="tb-change-card" href="#/account/payment" data-thread-tie="Checkout">Change</a></div>
</div>
<div class="tb-card">
<label class="tb-field" for="tb-note">Delivery note<textarea class="mv-textarea" id="tb-note" rows="2" placeholder="Gate code, safe place, delivery hours…"></textarea></label>
</div>
<div class="tb-card tb-sum">
<div><span>Subtotal</span><span>$389.00</span></div>
<div><span>Shipping</span><span>$0.00</span></div>
<div><span>Estimated tax</span><span>$23.00</span></div>
<div><span>Total</span><span>$412.00</span></div>
</div>
<div class="tb-actions"><button type="button" class="mv-button">Place order · $412.00</button></div>
</div>
</template>
<template data-route="/account/payment" data-title="Payment methods">
<div class="tb-page">
<div class="tb-head"><div><h3>Payment methods</h3><p>Cards saved to your account</p></div></div>
<ul class="tb-list">
<li class="tb-item"><span class="tb-card-ico">VISA</span><span class="tb-grow"><b>Visa ending in 4242</b><small>Expires 08/2027 · Default</small></span></li>
<li class="tb-item"><span class="tb-card-ico">MC</span><span class="tb-grow"><b>Mastercard ending in 8210</b><small>Expired 06/2026</small></span><span class="mv-badge" data-variant="warning" data-shape="pill">Expired</span></li>
</ul>
<div class="tb-actions"><a class="mv-button" data-variant="outline" href="#/account/payment/new">Add a new card</a></div>
</div>
</template>
<template data-route="/account/payment/new" data-title="Add a card">
<div class="tb-page">
<div class="tb-head"><div><h3>Add a card</h3><p>Your bank may ask you to verify it.</p></div></div>
<div class="tb-card">
<label class="tb-field">Name on card<input class="mv-input" value="Maya Chen" autocomplete="off"></label>
<label class="tb-field">Card number<input class="mv-input" value="4000 0566 5566 4417" inputmode="numeric" autocomplete="off"></label>
<div class="tb-grid2">
<label class="tb-field">Expiry<input class="mv-input" value="11 / 29" autocomplete="off"></label>
<label class="tb-field">Security code<input class="mv-input" value="•••" autocomplete="off"></label>
</div>
</div>
<div class="tb-actions"><a class="mv-button" href="#/account/payment/new/verify">Continue</a></div>
</div>
</template>
<template data-route="/account/payment/new/verify" data-title="Verify card">
<div class="tb-page">
<div class="tb-head"><div><h3>Verify your card</h3><p>Visa ending in 4417</p></div></div>
<div class="tb-card">
<p class="tb-note">Enter the 6-digit code your bank sent to (•••) •••-0187.</p>
<label class="tb-field">Verification code<input class="mv-input" value="482 915" inputmode="numeric" autocomplete="one-time-code"></label>
</div>
<div class="tb-actions">
<button type="button" class="mv-button" data-thread-return>Verify and return to checkout</button>
<button type="button" class="mv-button" data-variant="ghost">Resend code</button>
</div>
</div>
</template>
<template data-route="/account/addresses" data-title="Addresses">
<div class="tb-page">
<div class="tb-head"><div><h3>Addresses</h3><p>Where your orders can ship</p></div></div>
<ul class="tb-list">
<li class="tb-item"><span class="tb-grow"><b>Home</b><small>1200 Market St, Apt 5B, San Francisco, CA 94102</small></span><span class="mv-badge" data-variant="secondary" data-shape="pill">Default</span></li>
<li class="tb-item"><span class="tb-grow"><b>Office</b><small>415 Mission St, Floor 12, San Francisco, CA 94105</small></span></li>
</ul>
<div class="tb-actions">
<button type="button" class="mv-button" data-thread-return>Use Office and return</button>
<a class="mv-button" data-variant="outline" href="#/account/addresses/new">Add an address</a>
</div>
</div>
</template>
<template data-route="/account/addresses/new" data-title="New address">
<div class="tb-page">
<div class="tb-head"><div><h3>New address</h3><p>Saved to your address book</p></div></div>
<div class="tb-card">
<label class="tb-field">Street address<input class="mv-input" value="88 Colin P Kelly Jr St" autocomplete="off"></label>
<div class="tb-grid2">
<label class="tb-field">City<input class="mv-input" value="San Francisco" autocomplete="off"></label>
<label class="tb-field">ZIP code<input class="mv-input" value="94107" autocomplete="off"></label>
</div>
</div>
<div class="tb-actions"><button type="button" class="mv-button" data-thread-return>Save and return</button></div>
</div>
</template>
</section>
<!-- 2. Analytics: a thread inside a thread (layered lines), tag aligned to the end -->
<section class="tb-app" id="tb-report" aria-label="Analytics app (demo)">
<div class="tb-chrome" aria-hidden="true"><span class="tb-dots"><i></i><i></i><i></i></span><span class="tb-url" id="tb-report-url">app.lumen.io/reports/q3</span></div>
<div class="tb-screen">
<header class="tb-bar">
<span class="tb-brand"><span class="tb-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg></span>Lumen Analytics</span>
<span class="tb-end"><span>Finance team</span><span class="tb-me" aria-hidden="true">AO</span></span>
</header>
<mv-thread-back id="tb-report-thread" placement="inline" align="end" track="manual" storage="none" scope="#tb-report" scroller="#tb-report-view"></mv-thread-back>
<main class="tb-view" id="tb-report-view" tabindex="-1" aria-label="Analytics page"></main>
</div>
<div class="tb-foot">
<p>From the report into a transaction, then a second thread from that transaction to the customer: two layered lines, and the path menu returns to either knot.</p>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="tb-report-replay">Replay</button>
<div class="tb-log" id="tb-report-log" aria-live="polite"></div>
</div>
<template data-route="/reports/q3" data-title="Q3 revenue">
<div class="tb-page" style="max-width:none">
<div class="tb-head"><div><h3>Q3 revenue by region</h3><p>July – September 2026 · USD</p></div></div>
<table class="tb-table">
<thead><tr><th scope="col">Region</th><th scope="col">July</th><th scope="col">August</th><th scope="col">September</th><th scope="col">Total</th></tr></thead>
<tbody>
<tr><th scope="row">Americas</th><td>$1,184,200</td><td>$1,242,900</td><td>$1,310,450</td><td>$3,737,550</td></tr>
<tr><th scope="row">EMEA</th><td>$812,300</td><td><a href="#/reports/q3/emea" data-thread-tie="Q3 revenue">$1,036,700</a></td><td>$874,100</td><td>$2,723,100</td></tr>
<tr><th scope="row">Asia Pacific</th><td>$604,850</td><td>$655,200</td><td>$702,960</td><td>$1,963,010</td></tr>
</tbody>
</table>
</div>
</template>
<template data-route="/reports/q3/emea" data-title="EMEA · August">
<div class="tb-page">
<div class="tb-head"><div><h3>EMEA · August 2026</h3><p>1,284 transactions · $1,036,700</p></div></div>
<ul class="tb-list">
<li class="tb-item"><span class="tb-grow"><b>TX-88213 · Fischer & Roth GmbH</b><small>Aug 14, 2026 · Munich</small></span><span>$184,000.00</span><a class="tb-link" href="#/tx/88213">Review</a></li>
<li class="tb-item"><span class="tb-grow"><b>TX-88190 · Nordlys AS</b><small>Aug 13, 2026 · Oslo</small></span><span>$42,750.00</span></li>
<li class="tb-item"><span class="tb-grow"><b>TX-88174 · Castellan Group</b><small>Aug 12, 2026 · Madrid</small></span><span>$18,320.00</span></li>
</ul>
</div>
</template>
<template data-route="/tx/88213" data-title="TX-88213">
<div class="tb-page">
<div class="tb-head"><div><h3>TX-88213</h3><p>Invoice payment · Aug 14, 2026</p></div><span class="mv-badge" data-variant="warning" data-shape="pill">Needs review</span></div>
<div class="tb-card">
<div class="tb-row"><div><span class="tb-k">Amount</span><p class="tb-v">$184,000.00</p></div><div><span class="tb-k">Customer</span><p class="tb-v">Lena Fischer · Fischer & Roth GmbH</p></div><a class="tb-link" href="#/customers/lena-fischer" data-thread-tie="TX-88213">Open customer</a></div>
</div>
</div>
</template>
<template data-route="/customers/lena-fischer" data-title="Lena Fischer">
<div class="tb-page">
<div class="tb-head"><div><h3>Lena Fischer</h3><p>Fischer & Roth GmbH · Customer since 2021</p></div></div>
<div class="tb-card"><div class="tb-row"><div><span class="tb-k">Billing contact</span><p class="tb-v">[email protected]</p></div><a class="tb-link" href="#/customers/lena-fischer/billing">Edit</a></div></div>
</div>
</template>
<template data-route="/customers/lena-fischer/billing" data-title="Billing contact">
<div class="tb-page">
<div class="tb-head"><div><h3>Billing contact</h3><p>Fischer & Roth GmbH</p></div></div>
<div class="tb-card">
<label class="tb-field">Email<input class="mv-input" value="[email protected]" autocomplete="off"></label>
</div>
<div class="tb-actions">
<button type="button" class="mv-button" data-thread-return>Save and return</button>
<button type="button" class="mv-button" data-variant="outline" data-thread-return="outer">Save and back to report</button>
</div>
</div>
</template>
</section>
<script type="module">
await customElements.whenDefined("mv-thread-back");
const $ = (id) => document.getElementById(id);
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
// A tiny client-side router, as an SPA would have: it renders a view, then calls thread.step() after each navigation.
function makeApp(root, thread, { host, log }) {
const view = root.querySelector(".tb-view");
const url = root.querySelector(".tb-url");
const say = (text) => { log.textContent = `${time()} · ${text}`; };
const go = (path, { focus = false } = {}) => {
const tpl = root.querySelector(`template[data-route="${path}"]`);
if (!tpl) return;
view.replaceChildren(tpl.content.cloneNode(true));
view.scrollTop = 0;
url.textContent = host + path;
thread.step({ label: tpl.dataset.title, href: `#${path}` });
if (focus) view.focus({ preventScroll: true });
};
root.addEventListener("click", (e) => {
const a = e.target.closest("a[href^='#/']");
if (!a || !root.contains(a)) return;
e.preventDefault();
go(a.getAttribute("href").slice(1), { focus: true });
});
// The router navigates; the thread closes itself when step() reaches the origin.
thread.addEventListener("mv-thread-return", (e) => {
e.preventDefault();
say(`mv-thread-return → ${e.detail.label} (${e.detail.steps} ${e.detail.steps === 1 ? "step" : "steps"}, via ${e.detail.via})`);
go(e.detail.href.slice(1));
});
thread.addEventListener("mv-thread-tie", (e) => say(`mv-thread-tie → ${e.detail.label}${e.detail.depth ? " (nested)" : ""}`));
thread.addEventListener("mv-thread-close", (e) => { if (!["returned", "passed"].includes(e.detail.reason)) say(`mv-thread-close → ${e.detail.thread.label} (${e.detail.reason})`); });
return { go, say, view };
}
/* 1 · Store checkout */
const shopThread = $("tb-shop-thread");
const shop = makeApp($("tb-shop"), shopThread, { host: "northwind.shop", log: $("tb-shop-log") });
// Save the half-typed delivery note with the thread…
shopThread.addEventListener("mv-thread-tie", (e) => {
const note = shop.view.querySelector("#tb-note");
if (note) e.detail.save({ note: note.value });
});
// …and put it back when the user returns.
shopThread.addEventListener("mv-thread-restore", (e) => {
const note = shop.view.querySelector("#tb-note");
if (note && e.detail.state?.note) note.value = e.detail.state.note;
shop.view.querySelector(".tb-restored")?.setAttribute("data-on", "");
shop.say(`mv-thread-restore → ${e.detail.label}: delivery note and scroll (${Math.round(e.detail.scroll?.y ?? 0)}px) restored`);
});
const playShop = () => {
shopThread.clear();
shop.go("/checkout");
shop.view.querySelector("#tb-note").value = "Leave it with the front desk, please. The lobby closes at 6 PM.";
shop.view.scrollTop = 190;
shopThread.tie({ label: "Checkout", focus: shop.view.querySelector("#tb-change-card") });
shop.go("/account/payment");
shop.go("/account/payment/new");
shop.go("/account/payment/new/verify");
shop.say("Checkout → Payment methods → Add a card → Verify card");
};
/* 2 · Analytics, nested threads */
const reportThread = $("tb-report-thread");
const report = makeApp($("tb-report"), reportThread, { host: "app.lumen.io", log: $("tb-report-log") });
const playReport = () => {
reportThread.clear();
report.go("/reports/q3");
reportThread.tie({ label: "Q3 revenue" });
report.go("/reports/q3/emea");
report.go("/tx/88213");
reportThread.tie({ label: "TX-88213" });
report.go("/customers/lena-fischer");
report.go("/customers/lena-fischer/billing");
report.say("Q3 revenue → EMEA · August → TX-88213 → Lena Fischer → Billing contact");
};
playShop();
playReport();
$("tb-shop-replay").addEventListener("click", playShop);
$("tb-report-replay").addEventListener("click", playReport);
</script>
</div>Cultural reference
Metamorphoses, book 8 (Theseus and the Minotaur) — Ovid (myth also told in Plutarch’s Life of Theseus) (8, book). Ariadne gives Theseus a ball of thread that he ties at the entrance and unwinds through the labyrinth, so that once his task is done he can follow it straight back out. In the UI, a thread is tied at the origin of a task, each navigation unwinds it one step, and one action follows it back to the origin with the page restored as it was left.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
placement | top | bottom | inline | top | top / bottom: fixed to that viewport edge, the tag hangs over the page. inline: an in-flow strip (place it right under your app header); it expands when a thread appears and collapses when it closes. |
align | start | center | end | start | Where the tag hangs along the edge. The thread grows from its knot toward the opposite side (logical, RTL-aware). |
track | location | manual | location | location: page loads (multi-page) and same-document navigations (Navigation API, or popstate / hashchange and link clicks as a fallback) are steps, labelled with the clicked link’s data-thread-hop or text, else the page title. manual: only step() calls count; call it from your router’s after-navigation hook. |
match | path | pathname | url | path | How two places are compared: path = pathname + query (default), pathname = ignores the query (filters, tracking params), url = also the hash. Hash routes (#/…, #!/…) are always compared with their hash. |
storage | session | none | session | session keeps threads per tab in sessionStorage so they survive full page loads (multi-page sites, reloads). none keeps them in memory only. |
name | string | default | Storage namespace, for several independent apps on the same origin. |
param | string | mv-thread | Query parameter added to a cross-document return link (?mv-thread=<id>) so the origin page knows it was reached by a return, even after a server redirect; removed from the URL on arrival with history.replaceState. Empty string disables it. |
timeout | time ("30m", "2h", ms) | 30m | Idle time after the last tie or step after which every thread is dropped (mv-thread-close with reason "timeout"). 0 disables it. |
max | number (1–3) | 3 | How many threads can nest. Tying one more replaces the innermost existing thread (reason "replaced"). |
scope | CSS selector | Only data-thread-* triggers inside the matching ancestor are handled (for several instances on one page). Default: the whole document. | |
scroller | CSS selector | Scroll container whose position is saved at tie time and restored on return. Default: the window. | |
shortcut | string ("alt+shift+b", "mod+alt+arrowup") | Optional keyboard shortcut that returns to the active origin; exposed with aria-keyshortcuts and shown in the path menu. mod = Cmd on Apple platforms, Ctrl elsewhere. Single-key shortcuts are ignored while typing. | |
data-thread-tie | string (origin label) | On a link or button anywhere in scope: clicking it ties a thread at the current place before the navigation happens. The value is the origin’s label (empty = current page title); data-thread-origin overrides the origin URL; the element gets focus back on return. | |
data-thread-hop | string | On a link: label of the step it leads to (instead of its text or the next page’s title) with track="location". | |
data-thread-return | "" | "outer" | On a link or button (“Save and return to checkout”): returns to the active origin, or to the outermost one with "outer". When no thread is active the element keeps its normal behavior, so an href works as a fallback. | |
data-state / data-layers | set by the component | data-state="shown | hidden" on the host, data-layers = number of visible threads, data-undo while the Undo offer is up. |
Properties
| Name | Type | Description |
|---|---|---|
active | Thread | null | Innermost thread the user is away from (read-only). Thread = { id, label, href, steps, depth, tiedAt, state, scroll, hops: [{ label, href, at }], restorable }. |
threads | Thread[] | Every thread, outermost first, including one just tied with 0 steps (read-only). |
steps | number | Steps back to the active origin, 0 when none (read-only). |
path | { label, href, at, knot }[] | The shared path, outermost origin first and the current place last; knot is the id of the thread tied there (read-only). |
restored | Thread | null | The last thread returned through, with its saved state: read it on page load when your script runs after the mv-thread-restore event (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (back, stepOne, stepMany, returnName, group, toggle, dismiss, dismissed, undo, panelTitle, panelSubtitle, here, panelReturn, panelReturnName, stopAll, shortcutHint, announceShown, announceReturn, announceDismissed, announceUndo). English defaults, {label} / {steps} / {n} placeholders. |
placement / align / track / match / storage / name / param / timeout / max / scope / scroller / shortcut | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
tie({ label?, href?, state?, restore?, focus?, scroll? }) | Ties a thread at the current place (or href). state is any JSON value saved with it (also settable from mv-thread-tie); restore(thread) is an in-memory callback used instead of navigating (reopen a drawer, a tab); focus is the element or selector to focus on return (default: the focused element); scroll: false skips saving the scroll position. Tying where a thread is already knotted re-ties it. Returns the Thread, or null if cancelled. |
step({ label?, href?, replace? }) | Records a navigation: one step deeper, or a walk back when the place is already on the path, or the arrival at an origin (the thread closes; as “returned” if a return was requested, else “reached”). replace updates the current step instead of adding one. Called for you with track="location". |
back({ thread?, via? }) | Returns to a thread’s origin (default: the active one; thread = id, index or Thread). Emits the cancelable mv-thread-return, then calls the thread’s restore callback, or navigates to its href (adding ?mv-thread=<id> across documents). Returns true when the component handled it. |
cut(thread?) | Lets go of a thread without navigating (reason "cut"). When it was the last one, the tag offers Undo for 6 seconds. |
clear() | Drops every thread (reason "cleared"). |
open() / close() | Opens or closes the path menu. |
Events
| Name | Description |
|---|---|
mv-thread-tie | Cancelable, before a thread is tied. detail: { label, href, depth, save(value) }: call save() with the state to keep (a form draft, filters); preventDefault() refuses the tie. |
mv-thread-step | Cancelable, before a new step is recorded. detail: { label, href, steps, thread }; assign detail.label to rename the step, preventDefault() to not count it (modal routes, tabs). |
mv-thread-return | Cancelable, when the user asks to go back (tag, path menu, shortcut, data-thread-return, back()). detail: { thread, label, href, steps, state, via: "button" | "panel" | "shortcut" | "link" | "api" }. preventDefault() and navigate with your router: the thread closes as returned when the origin is reached (step() or location tracking). |
mv-thread-restore | Cancelable, once the origin is reached through a return. detail: { thread, label, state, scroll, restorable }. Put the saved state back here; preventDefault() skips the built-in scroll and focus restoration. On a full page load it is dispatched after the current task, so page scripts can listen first (see also restored). |
mv-thread-close | A thread ended. detail: { thread, reason: "returned" | "reached" (origin reached another way) | "passed" (an outer origin was reached, or Back walked above it) | "cut" | "cleared" | "replaced" | "timeout" }. |
CSS classes
| Name | Description |
|---|---|
mv-thread-back-rail | Decorative layer (aria-hidden): one .mv-thread-back-line per visible thread (-fill scaled to its length, -knot, -bead per step with data-here on the last; data-active on the innermost, data-depth on outer ones), the hanging -string and a -spark that runs along the thread on each step. |
mv-thread-back-tag | The <nav aria-label="Way back"> tag: .mv-thread-back-return (a link, or a button for restore-only threads) with -icon, -lead, -name, -steps; -toggle (path menu), -cut (dismiss); .mv-thread-back-undo with -undo-btn after a dismissal. |
mv-thread-back-panel | Path menu (Popover API top layer, non-modal dialog): -panel-title, -panel-sub, ol.mv-thread-back-path of .mv-thread-back-node[data-kind="knot | hop | here"] with -dot, -node-name, -node-meta and a Return button on knots, -foot with the shortcut hint and “Stop tracking”. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-thread-back-color | var(--mv-accent) | Thread color: lines, knots, beads, string, icon tint. |
--mv-thread-back-ink | var(--mv-accent-fg) | Readable text/icon color derived from the thread (icon, step count, Undo). |
--mv-thread-back-thickness | 2px | Line thickness. |
--mv-thread-back-layer-gap | 5px | Distance between layered lines of nested threads. |
--mv-thread-back-step | 44px | Length each step adds to the line (compressed when the edge runs out). |
--mv-thread-back-inset | var(--mv-space-4) | Distance between the tag and the side of the edge. |
--mv-thread-back-drop | 12px | Length of the string the tag hangs from. |
--mv-thread-back-offset | 3px | Distance between the edge and the first line. |
--mv-thread-back-spark | color-mix(accent, fg-on-accent) | Highlight that runs along the thread on each new step. |
Accessibility
The tag is a <nav aria-label="Way back"> landmark that only exists in the accessibility tree while a thread is active (visibility hidden otherwise). Its main control is a real link to the origin (a button for restore-only threads), so it works with middle-click and open-in-new-tab, and its accessible name contains the visible text: “Back to Checkout, 3 steps back”; the optional shortcut is exposed with aria-keyshortcuts and shown in the path menu. The path toggle has aria-expanded / aria-controls and opens a labelled, non-modal dialog right after the tag in the DOM with an ordered list (the current place has aria-current="location", every knot a Return button named “Return to Q3 revenue, 4 steps back”); Escape or an outside click closes it and focus goes back to the toggle. The dismiss button is named after its origin (“Dismiss the way back to Checkout”) and, when it removes the last thread, focus moves to an Undo button for 6 seconds instead of being lost. Announcements go through one polite live region and never repeat: once when a thread’s control first appears (“Back to Checkout is available…”), then on return (“Back at Checkout.”) and on dismissal; steps are never announced. After a return, the scroll position is restored and focus goes back to the control that tied the thread (the “Change” link), so keyboard and screen reader users resume exactly where they left. Lines, knots, beads and the string are aria-hidden and pointer-events: none; the step count is always written in words, never color alone. Reduced motion (OS or data-motion="reduce"): the line and beads appear at their length without growing, no spark runs along the thread, the tag fades without sliding. Forced colors: lines and knots use CanvasText / Highlight, the tag and menu keep visible borders and focus outlines.