익스클루시브
Thread Back <mv-thread-back>
작업을 벗어나는 깊은 탐색을 위한 복귀 실입니다. 출발점(결제, 보고서, 온보딩 단계)에 tie({ label, href, state, restore }) 또는 data-thread-tie 링크로 실을 묶어 두면, 이후의 모든 탐색이 한 단계씩 실을 되감습니다.
문화적 레퍼런스
변신 이야기 8권(테세우스와 미노타우로스), 오비디우스(플루타르코스의 테세우스전에서도 전해지는 신화)(8, 책). 아리아드네는 테세우스에게 실타래를 주고, 테세우스는 그 실을 입구에 묶어 미궁 속으로 풀어 나가며, 임무를 마친 뒤 실을 따라 곧장 밖으로 돌아올 수 있습니다. UI에서는 작업의 출발점에 실을 묶고, 이동할 때마다 실이 한 단계씩 풀리며, 한 번의 동작으로 실을 따라 출발점으로 돌아가면 페이지가 떠날 때 그대로 복원됩니다.
작동 방식
작업을 벗어나는 깊은 드릴다운을 위한 귀환 실입니다. 출발점(결제, 보고서, 온보딩 단계)에서 tie({ label, href, state, restore }) 또는 data-thread-tie 링크로 실을 묶으면, 그 이후의 모든 이동이 실을 한 단계씩 풉니다. 페이지 가장자리에 고정된 가는 선에 단계마다 구슬이 하나씩 늘어나고, 매듭에 매달린 태그(“Back to Checkout · 3 steps”)가 한 번의 동작으로 사용자를 출발점으로 곧장 데려간 뒤 스크롤 위치, 포커스된 컨트롤, 그리고 묶을 때 앱이 저장한 상태(폼 초안, 필터)를 복원합니다. 경로는 정직합니다: 브라우저의 뒤로 버튼으로 되돌아가거나 다른 링크로 출발점에 도달하면 실이 되감기거나 닫히고, 반복 이동은 결코 횟수를 부풀리지 않으며, 유휴 시간이 지나면 만료됩니다. 실은 중첩되며(실 안의 실은 최대 3개까지 겹친 선으로 표시) 경로 메뉴는 모든 단계를 각 매듭의 Return 동작과 함께 나열합니다. SPA(Navigation API 추적 또는 라우터에서 호출하는 step(), 그리고 라우터가 이동을 처리하도록 하는 취소 가능한 mv-thread-return)와 멀티 페이지 사이트(탭별 sessionStorage, 도착 시 정리되는 ?mv-thread=<id>를 담은 출발점 링크) 모두에서 동작하며, 선택적인 키보드 단축키와 닫은 뒤의 Undo를 제공합니다.
| 카테고리 | 내비게이션 |
|---|---|
| 유형 | Web Component (<mv-thread-back>) |
| 상태 | 안정 |
| 키트 | 파워 유저를 위한 내비게이션 |
| 함께 설치되는 항목 | 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
설치
node scripts/add.mjs thread-back --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["thread-back"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/kbd/kbd.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.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<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>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<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 }
#tb-demo #tb-report .tb-screen { height:28rem }
}
</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">tidewell.example.com/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>Tidewell 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">brightloom.example.com/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>Brightloom 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: "tidewell.example.com", 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: "brightloom.example.com", 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>API
Attributes
| Name | 유형 | 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 | 유형 | 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. Known limits: threads live in the tab (sessionStorage), so they do not follow a link opened in a new tab or cross to another origin; in browsers without the Navigation API, a route change made from script rather than a link click must be reported with step(); restoring focus needs the tied control to have an id, href or name to be found again.