ExklusivBeta
Thread Back <mv-thread-back>
Ein Rückweg-Faden für tiefe Drill-downs, die eine Aufgabe verlassen: Knüpfe ihn an einem Ausgangspunkt (Checkout, Bericht, Onboarding-Schritt) mit tie({ label, href, state, restore }) oder einem Link data-thread-tie, und jede Navigation danach wickelt ihn um einen Schritt ab. Eine schmale, am Seitenrand verankerte Linie bekommt pro Schritt eine Perle, und ein an ihrem Knoten hängendes Schild („Back to Checkout · 3 steps“) bringt den Nutzer mit einer einzigen Aktion direkt zum Ausgangspunkt zurück und stellt dann die Scrollposition, das fokussierte Bedienelement und den Zustand wieder her, den die App beim Knüpfen gespeichert hat (einen Formularentwurf, Filter). Der Weg ist ehrlich: Wer mit dem Zurück-Button des Browsers zurückgeht oder den Ausgangspunkt über einen anderen Link erreicht, spult den Faden zurück oder schließt ihn, Schleifen blähen die Zählung nie auf, und nach einer Leerlaufzeit läuft er ab. Fäden lassen sich verschachteln (ein Faden im Faden erscheint als geschichtete Linien, bis zu 3), und ein Pfadmenü listet jeden Schritt mit einer Return-Aktion an jedem Knoten. Funktioniert in SPAs (Tracking über die Navigation API oder step() aus deinem Router, dazu ein abbrechbares mv-thread-return, damit dein Router navigiert) und auf Multi-Page-Sites (sessionStorage pro Tab, ein Link zum Ausgangspunkt mit ?mv-thread=<id>, der bei der Ankunft bereinigt wird), mit optionalem Tastenkürzel und einem Undo nach dem Schließen.
| Kategorie | Navigation |
|---|---|
| Typ | Web Component (<mv-thread-back>) |
| Status | Beta |
| Kit | Navigation für Power-User |
| Installiert auch | 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
Installation
node scripts/add.mjs thread-back --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["thread-back"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Kopierte Dateien (inklusive Abhängigkeiten): 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.
Verwendung
Schnellstart mit dem kleinsten funktionierenden Markup:
<mv-thread-back></mv-thread-back>
<!-- on the origin page (e.g. checkout): -->
<a href="/account/cards/new" data-thread-tie="Checkout">Add a new card</a>Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<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.test/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">Card 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">DEBIT</span><span class="tb-grow"><b>Debit card ending in 4242</b><small>Expires 08/2027 · Default</small></span></li>
<li class="tb-item"><span class="tb-card-ico">CREDIT</span><span class="tb-grow"><b>Credit card 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>Card 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.test/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.test", 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.test", 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>Kulturelle Referenz
Metamorphosen, Buch 8 (Theseus und der Minotaurus), Ovid (Mythos, auch in Plutarchs Leben des Theseus erzählt) (8, Buch). Ariadne gibt Theseus ein Garnknäuel, das er am Eingang festbindet und durch das Labyrinth abwickelt, damit er ihm nach getaner Aufgabe direkt wieder hinaus folgen kann. In der UI wird ein Faden am Ausgangspunkt einer Aufgabe geknüpft, jede Navigation wickelt ihn um einen Schritt ab, und eine einzige Aktion folgt ihm zurück zum Ausgangspunkt, wobei die Seite so wiederhergestellt wird, wie sie verlassen wurde.
API
Attributes
| Name | Typ | 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 | Typ | 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.