独家
Finger Hold <mv-finger-hold>
分支向导,任何决策页面都可以用“手指”夹住:选择一个选项,探索它会通向哪里(后续问题、价格、最终方案),然后直接翻回夹住的页面,一切保持你离开时的样子,包括字段。
文化出处
Choose Your Own Adventure,Edward Packard & R. A. Montgomery(Bantam Books)(1979 年,书籍)。每一页都以一个决定结尾,把读者引向另一页;读者会用一根手指夹住决策页,偷看某个选择会通向哪里,再翻回来做出不同的选择。在 UI 中,任何决策步骤都可以用手指夹住:用户探索某个回答的后果,翻回被夹住的那一页时所有回答都完好无损,能看到偷看到的内容写在对应选项上,松开手指即确认。
工作原理
分支式向导,任何决策页都可以用一根“手指”夹住:选一个选项,探索它通向哪里(后续问题、价格、最终方案),然后直接翻回被夹住的那一页,一切都和你离开时完全一样,字段内容也不例外。步骤就是普通的子 section(data-step),其中的选项指向其他步骤(data-goto),因此流程是一张真正的图,而不是线性步进器。最多 4 根手指以带编号、按颜色区分的标签叠放在页面顶边,被夹住的页面在下方露出;点一下标签(或按 1-4、R)即可返回,标签上的 × 会松开手指,“Keep this path”会松开全部手指,确认你正在探索的路径。访问过的一切都保存在一棵探索树中:在放弃的分支上看到的内容会写回通向那里的选项上(“Peeked: Bronze HSA 3000 · $572/mo”,取自 outcome 指定的任意元素),路径图会画出当前路径、夹住的节点、偷看过的分支以及尚未探索的选项。提供可取消的 mv-navigate / mv-hold / mv-return / mv-complete 事件、逐步校验、可选的 History API 同步(浏览器的后退会在流程中回退),以及可覆盖的英文文案。
| 分类 | 导航 |
|---|---|
| 类型 | Web Component(<mv-finger-hold>) |
| 状态 | 稳定版 |
| 同时安装 | button, kbd |
| Keywords | exclusive, culture, wizard, branching, decision-tree, multi-step, onboarding, configurator, plan-selector, insurance, troubleshooting, compare, what-if, bookmark, path-map, history-api, keyboard |
When to use
- A plan, pricing or insurance selector where people want to compare what each answer leads to before committing
- An onboarding or setup flow whose questions branch, and users should explore a path without losing their earlier answers
- A troubleshooting guide or decision tree where agents test one fix and come back to the symptom page to try the next
- A product configurator whose options open different follow-up questions and change the final price
Avoid when
安装
node scripts/add.mjs finger-hold --out ./src/marvelous使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["finger-hold"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.css, components/kbd/kbd.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/finger-hold/finger-hold.js, components/finger-hold/finger-hold.css。
用法
标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:
<div id="fh-demo" style="width:min(100%,62rem);margin-inline:auto">
<style>
#fh-demo { display:grid; gap:1.25rem; align-content:start }
#fh-demo .fh-head { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap }
#fh-demo .fh-head h3 { margin:0; font-size:1.125rem; letter-spacing:-.015em }
#fh-demo .fh-head p { margin:.25rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; max-width:34rem }
#fh-demo .fh-keys { display:flex; gap:.375rem 1rem; flex-wrap:wrap; color:var(--mv-fg-subtle); font-size:.75rem }
#fh-demo .fh-keys span { display:inline-flex; align-items:center; gap:.375rem }
#fh-demo [data-step] h4 { margin:0; font-size:1.0625rem; letter-spacing:-.01em; line-height:1.3 }
#fh-demo [data-step] > p { margin:.375rem 0 1.125rem; color:var(--mv-fg-muted); font-size:.8125rem; max-width:36rem }
#fh-demo .fh-plan-head { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap }
#fh-demo .fh-facts { display:grid; grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr)); gap:.5rem; margin:0 0 1rem; padding:0 }
#fh-demo .fh-facts div { padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle) }
#fh-demo .fh-facts dt { color:var(--mv-fg-subtle); font-size:.6875rem; text-transform:uppercase; letter-spacing:.04em }
#fh-demo .fh-facts dd { margin:.125rem 0 0; font-size:.875rem; font-weight:600; font-variant-numeric:tabular-nums }
#fh-demo .fh-finish { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding-top:1rem; border-top:1px solid var(--mv-border) }
#fh-demo .mv-choice { font-size:.8125rem }
#fh-demo .fh-quote { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:.75rem 1.5rem; padding:.875rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#fh-demo .fh-quote-label { display:block; color:var(--mv-fg-subtle); font-size:.6875rem; text-transform:uppercase; letter-spacing:.05em }
#fh-demo .fh-price { font-size:1.375rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#fh-demo .fh-quote-note { color:var(--mv-fg-muted); font-size:.8125rem }
#fh-demo .fh-log { margin:0; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono); min-height:1.1rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#fh-demo .fh-controls { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; justify-content:flex-end }
#fh-demo .fh-variant { display:grid; grid-template-columns:minmax(0,15rem) minmax(0,1fr); gap:1.5rem; align-items:start; padding-top:.5rem }
#fh-demo .fh-variant-copy h3 { margin:0; font-size:.9375rem }
#fh-demo .fh-variant-copy p { margin:.375rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#fh-demo .fh-steps { margin:.25rem 0 1rem; padding-left:1.125rem; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.6 }
@media (max-width:44rem) {
#fh-demo .fh-quote { grid-template-columns:1fr }
#fh-demo .fh-controls { justify-content:flex-start }
#fh-demo .fh-variant { grid-template-columns:1fr }
}
</style>
<div class="fh-head">
<div>
<h3>Find your health plan</h3>
<p>Hold a decision with a finger, peek at where an answer leads, then flip straight back and try the other one. Nothing you entered is lost.</p>
</div>
<div class="fh-keys" aria-hidden="true">
<span><kbd class="mv-kbd">H</kbd> hold page</span>
<span><kbd class="mv-kbd">R</kbd> return</span>
<span><kbd class="mv-kbd">K</kbd> keep path</span>
<span><kbd class="mv-kbd-group"><kbd class="mv-kbd">⇧</kbd><kbd class="mv-kbd">Enter</kbd></kbd> peek a choice</span>
</div>
</div>
<!-- Main: a health-insurance plan finder with a live quote -->
<mv-finger-hold id="fh-plans" label="Health plan finder" outcome="#fh-price">
<section data-step="coverage" data-title="Coverage">
<h4>Who needs coverage?</h4>
<p>We size the plan to your household and estimate your monthly premium as you go.</p>
<div class="mv-finger-hold-choices">
<button type="button" class="mv-finger-hold-choice" data-goto="care" data-choice="me"><strong>Just me</strong><span>Individual coverage</span><span class="mv-finger-hold-choice-meta">1 person</span></button>
<button type="button" class="mv-finger-hold-choice" data-goto="care" data-choice="couple"><strong>Me and my partner</strong><span>Spouse or domestic partner</span><span class="mv-finger-hold-choice-meta">2 people</span></button>
<button type="button" class="mv-finger-hold-choice" data-goto="kids" data-choice="family"><strong>My family</strong><span>Partner and children under 26</span><span class="mv-finger-hold-choice-meta">3+ people</span></button>
</div>
</section>
<section data-step="kids" data-title="Children">
<h4>How many children are on the plan?</h4>
<p>Children can stay on a family plan until they turn 26.</p>
<div class="mv-finger-hold-choices">
<button type="button" class="mv-finger-hold-choice" data-goto="care" data-choice="1"><strong>1 child</strong><span>3 people in total</span></button>
<button type="button" class="mv-finger-hold-choice" data-goto="care" data-choice="2"><strong>2 children</strong><span>4 people in total</span></button>
<button type="button" class="mv-finger-hold-choice" data-goto="care" data-choice="3"><strong>3 or more</strong><span>Premiums cap at 3 children</span></button>
</div>
</section>
<section data-step="care" data-title="Care needs">
<h4>How often do you see a doctor?</h4>
<p>Think about the last twelve months, prescriptions included.</p>
<div class="mv-finger-hold-choices">
<button type="button" class="mv-finger-hold-choice" data-goto="deductible" data-choice="rarely"><strong>A checkup or two a year</strong><span>Mostly preventive care, rare prescriptions</span></button>
<button type="button" class="mv-finger-hold-choice" data-goto="network" data-choice="often"><strong>Regularly</strong><span>Ongoing prescriptions or a specialist</span></button>
</div>
</section>
<section data-step="deductible" data-title="Deductible">
<h4>Choose your deductible</h4>
<p>A higher deductible lowers your premium, but you pay more out of pocket before coverage starts.</p>
<div class="mv-finger-hold-choices">
<button type="button" class="mv-finger-hold-choice" data-goto="silver" data-choice="low" data-label="$750 deductible"><strong>$750 deductible</strong><span>$30 copays from day one</span><span class="mv-finger-hold-choice-meta">Higher premium</span></button>
<button type="button" class="mv-finger-hold-choice" data-goto="bronze" data-choice="high" data-label="$3,000 deductible"><strong>$3,000 deductible</strong><span>Pairs with a tax-free HSA</span><span class="mv-finger-hold-choice-meta">Lowest premium</span></button>
</div>
</section>
<section data-step="network" data-title="Doctors">
<h4>Do you want to keep your current doctors?</h4>
<p>Networks keep premiums down; open access costs more but never needs a referral.</p>
<div class="mv-finger-hold-choices">
<button type="button" class="mv-finger-hold-choice" data-goto="platinum" data-choice="any"><strong>Yes, any licensed doctor</strong><span>PPO: no referrals, out-of-network covered</span></button>
<button type="button" class="mv-finger-hold-choice" data-goto="gold" data-choice="network"><strong>A network is fine</strong><span>HMO: pick a primary doctor, referrals for specialists</span></button>
</div>
</section>
<section data-step="bronze" data-title="Bronze HSA 3000" data-end>
<div class="fh-plan-head"><h4>Bronze HSA 3000</h4><span class="mv-badge" data-variant="secondary">Lowest premium</span></div>
<p>Best if you rarely need care and want to save pre-tax dollars for the years you do.</p>
<dl class="fh-facts">
<div><dt>Deductible</dt><dd>$3,000</dd></div>
<div><dt>Out-of-pocket max</dt><dd>$8,050</dd></div>
<div><dt>Primary care</dt><dd>After deductible</dd></div>
</dl>
<div class="fh-finish">
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="dental" value="yes"> Add dental & vision (+$24 per person)</label>
<button type="button" class="mv-button" data-finish>Choose Bronze HSA</button>
</div>
</section>
<section data-step="silver" data-title="Silver 750" data-end>
<div class="fh-plan-head"><h4>Silver 750</h4><span class="mv-badge" data-variant="success">Most popular</span></div>
<p>Predictable copays for everyday care, with a deductible you can actually reach.</p>
<dl class="fh-facts">
<div><dt>Deductible</dt><dd>$750</dd></div>
<div><dt>Out-of-pocket max</dt><dd>$6,500</dd></div>
<div><dt>Primary care</dt><dd>$30 copay</dd></div>
</dl>
<div class="fh-finish">
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="dental" value="yes"> Add dental & vision (+$24 per person)</label>
<button type="button" class="mv-button" data-finish>Choose Silver 750</button>
</div>
</section>
<section data-step="gold" data-title="Gold HMO" data-end>
<div class="fh-plan-head"><h4>Gold HMO</h4><span class="mv-badge" data-variant="info">Low out-of-pocket</span></div>
<p>Low costs when you use care often, as long as you stay in the network.</p>
<dl class="fh-facts">
<div><dt>Deductible</dt><dd>$250</dd></div>
<div><dt>Out-of-pocket max</dt><dd>$4,800</dd></div>
<div><dt>Primary care</dt><dd>$20 copay</dd></div>
</dl>
<div class="fh-finish">
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="dental" value="yes"> Add dental & vision (+$24 per person)</label>
<button type="button" class="mv-button" data-finish>Choose Gold HMO</button>
</div>
</section>
<section data-step="platinum" data-title="Platinum PPO" data-end>
<div class="fh-plan-head"><h4>Platinum PPO</h4><span class="mv-badge" data-variant="outline">Any doctor</span></div>
<p>No deductible and no referrals, in or out of network.</p>
<dl class="fh-facts">
<div><dt>Deductible</dt><dd>$0</dd></div>
<div><dt>Out-of-pocket max</dt><dd>$3,500</dd></div>
<div><dt>Any doctor</dt><dd>$15 copay</dd></div>
</dl>
<div class="fh-finish">
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="dental" value="yes"> Add dental & vision (+$24 per person)</label>
<button type="button" class="mv-button" data-finish>Choose Platinum PPO</button>
</div>
</section>
</mv-finger-hold>
<div class="fh-quote">
<div>
<span class="fh-quote-label">Estimated premium</span>
<span class="fh-price" id="fh-price">$778/mo</span>
</div>
<div style="min-width:0">
<div class="fh-quote-note" id="fh-note">Silver 750 for 2 people</div>
<p class="fh-log" id="fh-log" aria-live="off">Ready.</p>
</div>
<div class="fh-controls">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="fh-history"> Browser Back steps through the flow</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fh-restart">Start over</button>
</div>
</div>
<!-- Variant: troubleshooting guide, no map, two fingers -->
<div class="fh-variant">
<div class="fh-variant-copy">
<h3>Troubleshooting guide</h3>
<p>Same mechanic without the map and with two fingers: support agents hold the symptom page and test each fix without losing their place.</p>
</div>
<mv-finger-hold id="fh-wifi" label="Wi-Fi troubleshooter" map="none" max-fingers="2">
<section data-step="symptom" data-title="Symptom">
<h4>What do you see on the laptop?</h4>
<p>Open the Wi-Fi menu in the top-right corner of the screen.</p>
<div class="mv-finger-hold-choices">
<button type="button" class="mv-finger-hold-choice" data-goto="radio" data-choice="empty"><strong>No networks listed</strong><span>The list is empty or grayed out</span></button>
<button type="button" class="mv-finger-hold-choice" data-goto="join" data-choice="listed"><strong>My network is listed</strong><span>It won’t join or keeps dropping</span></button>
</div>
</section>
<section data-step="radio" data-title="Wireless radio">
<h4>Is airplane mode on?</h4>
<p>Look for the plane icon in the menu bar or in Quick Settings.</p>
<div class="mv-finger-hold-choices">
<button type="button" class="mv-finger-hold-choice" data-goto="fix-airplane" data-choice="yes"><strong>Yes</strong></button>
<button type="button" class="mv-finger-hold-choice" data-goto="fix-driver" data-choice="no"><strong>No</strong></button>
</div>
</section>
<section data-step="join" data-title="Joining">
<h4>What happens when you pick the network?</h4>
<p>Try once more and watch the status line under the network name.</p>
<div class="mv-finger-hold-choices">
<button type="button" class="mv-finger-hold-choice" data-goto="fix-password" data-choice="rejected"><strong>The password is rejected</strong></button>
<button type="button" class="mv-finger-hold-choice" data-goto="fix-router" data-choice="timeout"><strong>It just times out</strong></button>
</div>
</section>
<section data-step="fix-airplane" data-title="Turn off airplane mode" data-end>
<h4>Turn off airplane mode</h4>
<ol class="fh-steps"><li>Open Quick Settings.</li><li>Turn airplane mode off, then Wi-Fi on.</li><li>Wait ten seconds for the list to refresh.</li></ol>
<button type="button" class="mv-button" data-size="sm" data-finish>This fixed it</button>
</section>
<section data-step="fix-driver" data-title="Reset the network adapter" data-end>
<h4>Reset the network adapter</h4>
<ol class="fh-steps"><li>Open Settings › Network › Advanced.</li><li>Choose “Network reset” and restart.</li><li>If the list stays empty, the adapter may need service.</li></ol>
<button type="button" class="mv-button" data-size="sm" data-finish>This fixed it</button>
</section>
<section data-step="fix-password" data-title="Re-enter the password" data-end>
<h4>Forget the network and re-enter the password</h4>
<ol class="fh-steps"><li>Right-click the network and choose “Forget”.</li><li>Join again; the password is case-sensitive.</li><li>Check the label under the router for the default key.</li></ol>
<button type="button" class="mv-button" data-size="sm" data-finish>This fixed it</button>
</section>
<section data-step="fix-router" data-title="Restart the router" data-end>
<h4>Restart the router</h4>
<ol class="fh-steps"><li>Unplug the router for 30 seconds.</li><li>Plug it back in and wait for a steady light.</li><li>Move within 30 feet and try again.</li></ol>
<button type="button" class="mv-button" data-size="sm" data-finish>This fixed it</button>
</section>
</mv-finger-hold>
</div>
<script type="module">
const fh = document.getElementById("fh-plans");
const wifi = document.getElementById("fh-wifi");
const price = document.getElementById("fh-price");
const note = document.getElementById("fh-note");
const log = document.getElementById("fh-log");
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
// A small, realistic pricing model: monthly premium per plan for one adult.
const PLANS = { bronze: ["Bronze HSA 3000", 286], silver: ["Silver 750", 389], gold: ["Gold HMO", 452], platinum: ["Platinum PPO", 561] };
const REACH = { coverage: Object.keys(PLANS), kids: Object.keys(PLANS), care: Object.keys(PLANS), deductible: ["silver", "bronze"], network: ["gold", "platinum"] };
function household() {
const who = fh.choices.find((c) => c.step === "coverage")?.choice;
const kids = Number(fh.choices.find((c) => c.step === "kids")?.choice ?? 0);
if (who === "couple") return { people: 2, factor: 2 };
if (who === "family") return { people: 2 + Math.max(1, kids), factor: 2 + 0.6 * Math.max(1, kids) };
return { people: 1, factor: 1 };
}
function quote() {
const step = fh.step;
const { people, factor } = household();
const who = `${people} ${people === 1 ? "person" : "people"}`;
if (PLANS[step]) {
const [name, base] = PLANS[step];
const dental = document.querySelector(`#fh-plans [data-step="${step}"] input[name="dental"]`)?.checked;
price.textContent = `${usd.format(base * factor + (dental ? 24 * people : 0))}/mo`;
note.textContent = `${name} for ${who}${dental ? " · dental & vision included" : ""}`;
} else {
const low = Math.min(...(REACH[step] ?? Object.keys(PLANS)).map((p) => PLANS[p][1]));
price.textContent = `from ${usd.format(low * factor)}/mo`;
note.textContent = fh.choices.length ? `Estimate for ${who}, narrowing down` : "Answer a few questions to see your price";
}
}
const say = (text) => { log.textContent = text; };
fh.addEventListener("mv-step", quote);
fh.addEventListener("change", quote);
fh.addEventListener("mv-hold", (e) => say(`mv-hold · finger ${e.detail.finger} on “${e.detail.label}”`));
fh.addEventListener("mv-return", (e) => say(`mv-return · back to “${e.detail.label}”, peeked ${e.detail.peek.text}`));
fh.addEventListener("mv-release", (e) => { if (e.detail.reason === "release" || e.detail.reason === "keep") say(`mv-release · “${e.detail.label}” (${e.detail.reason})`); });
fh.addEventListener("mv-complete", (e) => {
note.textContent = `Selected: ${e.detail.outcome.text}${e.detail.values.dental ? " with dental & vision" : ""}`;
say(`mv-complete · ${e.detail.path.join(" › ")}`);
});
document.getElementById("fh-history").addEventListener("change", (e) => { fh.history = e.target.checked; });
document.getElementById("fh-restart").addEventListener("click", () => { fh.reset(); quote(); say("Started over."); });
await customElements.whenDefined("mv-finger-hold");
// Start mid-exploration: two fingers held, one branch already peeked.
fh.choose("couple");
fh.hold(); // finger 1 on "Care needs"
fh.choose("rarely");
fh.hold(); // finger 2 on "Deductible"
fh.choose("high"); // peek at the $3,000 plan…
fh.returnTo(2); // …flip back: the choice now reads "Peeked: Bronze HSA 3000 · $572/mo"
fh.choose("low"); // and now exploring the $750 one
quote();
say("Two fingers held. Press R to flip back to Deductible.");
wifi.hold();
wifi.choose("listed");
</script>
</div>API
Attributes
| Name | 类型 | Default | Description |
|---|---|---|---|
start | step id | Step shown first. Defaults to the first data-step child. | |
max-fingers | number (1-5) | 4 | How many decision pages can be held at once. When all are in use the “Hold this page” tab turns disabled and explains why; lowering it releases the deepest fingers. |
outcome | CSS selector | Element whose text sums up the current result (a price, a plan name). Read when the user comes back up from a branch and written on the choice that led there as “Peeked: {step} · {outcome}”. Looked up in the current step, then the component, then the document. Without it, a step’s data-outcome or its title is used. | |
history | boolean | Sync with the History API: every step change pushes an entry (state only, the URL is untouched) and browser Back / Forward walk the exploration (a finger’s fields are restored when you land on its page, fingers off the new path are released). Other keys of history.state are preserved. | |
label | string | Decision flow | Accessible name of the component (role="group"). |
map | auto | side | bottom | none | auto | Where the path map goes: auto puts it beside the page from 720px of component width, under it otherwise. |
shortcuts | "true" | "false" | true | Single-key shortcuts while focus is inside the component and not in a text field: H hold / release this page, R return to the latest finger, K keep this path, 1-5 return to that finger. Shift+Enter or Shift+click on a choice (hold, then choose) works either way. |
data-step | string (on a direct child) | Declares a step. data-title sets its name (otherwise its first heading), data-end marks a result page (also implied when it has no choices), data-outcome gives a static outcome text, data-hold="false" prevents holding it. A step needs at least two choices to be holdable. | |
data-goto | step id (on a button or link inside a step) | A choice leading to that step. data-choice is its id (defaults to the target, required when two choices share a target), data-label its short name (otherwise its first <strong> or its text), data-value what is reported in events. The step’s fields are validated before leaving unless data-novalidate is present. | |
data-finish | marker (inside a step) | Completes the flow from this page: emits the cancelable mv-complete with the committed path and the named field values, then releases every finger. | |
data-mode / data-fingers / data-current-step / data-layout / data-complete | set by the component | On the host, for styling: free | exploring | here | done, number of fingers, current step id, side | bottom | none, present once completed. Choices get data-peeked when a branch behind them was explored; the visible step gets data-current. |
Properties
| Name | 类型 | Description |
|---|---|---|
step | string | Current step id. Setting it navigates like go(). |
path | string[] | Step ids of the current path, start first (read-only). |
choices | { step, choice, label, value, to }[] | Decisions taken along the current path (read-only). |
fingers | { number, step, label, depth, heldAt, here, peeks: { choice, label, outcome }[] }[] | Held fingers, shallowest first, with what was peeked from each (read-only). |
exploring | boolean | True when at least one finger is held and the user is past it (read-only). |
steps | string[] | Declared step ids in document order (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (keys: label, back, step, result, hold, holdFull, holdFullHint, exploring, heldHere, completed, returnTo, keep, tabs, tabReturn, tabHere, tabDescription, release, peeked, mapTitle, steps1, stepsN, held0, held1, heldN, mapHere, peeked1, peekedN, more1, moreN, mapItemHeld, mapItemPeeks, mapItemMore1, mapItemMoreN, legendPath, legendPeeked, legendOpen, legendHeld, announceStep, announceHold, announceReturn, announceReturnPeek, announceRelease, announceKeep, announceFull, announceComplete). English defaults. |
Methods
| Name | Description |
|---|---|
choose(choice, { hold? }) | Takes a choice of the current step: its element, data-choice id or data-goto target. hold: true holds the page first. Returns false if refused. |
go(stepId) | Goes back up the path to that step when it is on it, or opens it as a new branch from the current page. |
back() | Previous step of the path (a finger on the page you leave is released). |
hold() | Holds the current page (emits the cancelable mv-hold). False when it is not a decision, is already held or all fingers are in use. |
returnTo(n?) | Flips back to finger n (1-based), default the latest one that is not the current page: restores the path and every field value as they were, releases the deeper fingers. |
release(n?) | Lets go of finger n (default the latest); the current path is kept. |
keep() | Releases every finger: the path being explored becomes the committed one. |
complete() | Same as a data-finish click, from code. |
reset() | Back to the start step, fingers released, exploration tree cleared, fields reset to their default values. |
refresh() | Re-reads steps and choices. Called automatically when children change (frameworks, CMS). |
Events
| Name | Description |
|---|---|
mv-navigate | Cancelable, before any step change. detail: { from, to, reason: "choice" | "back" | "return" | "api" | "history", choice: { id, label, value } | null, path }. Not cancelable for history. |
mv-step | After a step change (fields already restored on a return). Same detail as mv-navigate; reason "reset" after reset(). |
mv-hold | Cancelable, before a page is held. detail: { step, label, finger, fingers }. |
mv-return | Cancelable, before flipping back to a finger. detail: { step, label, finger, from, peek: { step, title, outcome, text } } where peek is what was on screen when the user left. |
mv-release | A finger was let go. detail: { step, label, finger, reason: "release" | "keep" | "return" | "back" | "complete" | "api" | "history" | "reset" }. |
mv-complete | Cancelable, on data-finish or complete(). detail: { step, path, choices, values (named fields of the path’s steps, like FormData), outcome, released (fingers let go) }. |
Content structure
| Name | Description |
|---|---|
(steps) | Direct children with data-step. They stay where you put them (only hidden / shown), so framework-rendered children are safe; any other child flows under the book. |
CSS classes
| Name | Description |
|---|---|
mv-finger-hold-choices / mv-finger-hold-choice | Optional choice-card grid and card (<strong> title, <span> details, .mv-finger-hold-choice-meta aside). Cards turn dashed once peeked. |
mv-finger-hold-peek | The “Peeked: …” note appended inside a peeked choice (aria-hidden, linked through aria-describedby). |
mv-finger-hold-tabs / -tab / -tab-num / -tab-label / -tab-return / -tab-release / -hold | Tab row on the top edge: one tab per finger (data-finger, data-here), and the dashed “Hold this page” ghost tab. |
mv-finger-hold-bar / -back / -step / -pill / -return / -keep | Page header: Back, step counter, exploring pill, Return and Keep this path. |
mv-finger-hold-sheet | The page card; held fingers show as stacked leaves under it (box-shadow). |
mv-finger-hold-map / -map-lines / -map-list / -map-legend | Path map: SVG lanes (current path straight down, branches to the side, dashed stubs for options not explored) next to an ordered list of the path. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-finger-hold-color-1 … -5 | accent, then hues turned around it | Color of each finger: tab strip, number, leaf edge, map badge, exploring tint. |
--mv-finger-hold-path | var(--mv-accent) | Current-path color in the map. |
--mv-finger-hold-map-width | 15.5rem | Width of the side map. |
Accessibility
The host is a labelled group. Only the current step is rendered (others are hidden), and every user-initiated change moves focus to the new step’s heading (tabindex="-1", no ring for pointer users) and announces “Step 3: Deductible.” in a polite live region; holds, returns (“Back at Deductible, as you left it. You peeked: Bronze HSA 3000 · $572/mo.”), releases and completion are announced there too, never assertively. Fingers are real buttons in a labelled group: each tab is named “Return to Deductible”, described “Finger 2 of 4, held at step 3.”, carries aria-keyshortcuts, and becomes aria-current="step" + aria-disabled on the page you are on; each × is “Release the finger on Deductible”. The ghost tab “Hold this page” (aria-keyshortcuts H) turns aria-disabled with an explanation when all fingers are in use. Return and Keep this path are labelled buttons with shortcuts R and K; shortcuts only fire while focus is inside the component and never in text fields, and can be turned off. Focus never gets lost: when a tab or button disappears, it moves to the next logical control or the step heading. Peek notes are aria-hidden inside the choice but linked through aria-describedby, so the choice keeps its name and gains “Peeked: …” as description. The map’s SVG is aria-hidden; its content is an ordered list of the path in which each step reads its answer, “Held by finger 2.”, what was peeked and how many options were not explored. Colors always come with numbers, words and shapes (numbered badges, dashed = not explored / peeked). Reduced motion (OS or data-motion="reduce"): pages, tabs and leaves change without sliding or springing. Forced colors: tabs, badges and paths use system colors.