Exclusivebeta
Butterfly — <mv-butterfly>
Impact preview for settings, pricing forms, editors and spreadsheet-like screens: before a value is changed, every value it will ripple into lights up. Controls declare what they affect with data-affects="#invoice-total, .tax-line" (or a graph property), dependents can declare their own dependents, and the component walks the chain transitively, cycles included. Hovering or focusing a control rings each dependent anywhere on the page with an order badge (1 = direct, 2 = through one intermediate…) and a line style per order, the ripple spreads hop by hop along faint connectors, and a floating summary that avoids covering what it describes counts it all (“Affects 14 values · 3 sections”) and lists the dependents hidden in scroll areas or off-screen, with jump links. Give it compute(change) and editing turns into a preview: each dependent shows a before → after ghost over its own value until the change is committed; mv-commit is cancelable and a veto restores the control and never reaches the app’s change handlers. Labels come from aria, <label>, <dt>, table row and column headers; sections from headings, legends and aria-labels.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-butterfly>) |
| Status | beta |
| Keywords | exclusive, culture, impact, dependencies, dependency-graph, preview, what-if, before-after, ripple, highlight, settings, pricing, spreadsheet, formulas, consequences, veto, cancelable, accessibility |
When to use
- A setting feeds totals, schedules or reports elsewhere on the page and users should see what a change will touch before making it
- A pricing, payroll or tax form needs a live before → after preview of every derived value while a slider is dragged
- A spreadsheet-like screen should reveal which cells depend on the one being edited, including cells scrolled out of view
- Risky changes must be vetoed by business rules (approval thresholds) with the control restored automatically
Avoid when
- The point is to flag values the system changed on its own, after the fact, not before a user change → use Deja Vu instead
- The dependency is a single obvious value right next to the control; a plain live label is enough
- The dependency graph lives only on the server and cannot be expressed as selectors or a graph object in the page
Install
node scripts/add.mjs butterfly --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["butterfly"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, components/butterfly/butterfly.js, components/butterfly/butterfly.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="bf-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#bf-demo .bf-layout { display:grid; grid-template-columns:19rem minmax(0,1fr); gap:1.25rem; align-items:start }
#bf-demo .bf-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#bf-demo .bf-settings { display:grid; gap:1.125rem; padding:1.125rem }
#bf-demo .bf-settings h3, #bf-demo .bf-doc h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#bf-demo .bf-sub { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
#bf-demo .bf-field { display:grid; gap:.5rem; font-size:.8125rem }
#bf-demo .bf-row { display:flex; justify-content:space-between; align-items:baseline; font-weight:500 }
#bf-demo .bf-row output { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums; font-weight:500 }
#bf-demo .bf-field > label.bf-lbl { font-weight:500 }
#bf-demo .bf-settings mv-number-field { --mv-number-field-width:100% }
#bf-demo .bf-sep { height:1px; background:var(--mv-border); margin:0 -1.125rem }
#bf-demo .bf-options { display:grid; gap:.75rem; padding:.875rem 1.125rem; margin-top:1rem; font-size:.8125rem }
#bf-demo .bf-k { margin:0; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#bf-demo .bf-options .mv-choice-description { font-size:.75rem }
#bf-demo .bf-log { display:flex; gap:.5rem; align-items:flex-start; margin:0; min-height:2.25rem; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45 }
#bf-demo .bf-log .mv-badge { flex:none }
#bf-demo .bf-doc { height:31rem; overflow:auto; padding:1.25rem 1.5rem 1.5rem; scroll-padding:1rem; overscroll-behavior:contain }
#bf-demo .bf-doc section + section { margin-top:1.5rem; padding-top:1.25rem; border-top:1px dashed var(--mv-border) }
#bf-demo .bf-doc h4 { margin:0 0 .75rem; font-size:.8125rem; font-weight:600; letter-spacing:.01em }
#bf-demo .bf-inv-head { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; margin-bottom:1rem }
#bf-demo .bf-inv-head p { margin:0 }
#bf-demo .bf-meta { color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
#bf-demo .bf-due { text-align:end }
#bf-demo .bf-due span { display:block; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#bf-demo .bf-due strong { font-size:1.625rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#bf-demo .bf-doc .mv-table { font-size:.8125rem }
#bf-demo .bf-doc .mv-table td, #bf-demo .bf-doc .mv-table th { font-variant-numeric:tabular-nums }
#bf-demo .bf-totals, #bf-demo .bf-list { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.5rem 1rem; margin:.875rem 0 0; font-size:.8125rem; font-variant-numeric:tabular-nums }
#bf-demo .bf-totals { margin-left:auto; max-width:19rem }
#bf-demo .bf-totals dt, #bf-demo .bf-list dt { color:var(--mv-fg-muted) }
#bf-demo .bf-totals dd, #bf-demo .bf-list dd { margin:0; text-align:end; font-weight:500 }
#bf-demo .bf-totals .bf-grand { padding-top:.5rem; border-top:1px solid var(--mv-border); color:var(--mv-fg); font-weight:600 }
#bf-demo .bf-list { margin:0 }
#bf-demo .bf-list dt small { display:block; color:var(--mv-fg-subtle); font-size:.6875rem }
#bf-demo .bf-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:52rem) { #bf-demo .bf-layout { grid-template-columns:minmax(0,1fr) } #bf-demo .bf-doc { height:auto; max-height:32rem } }
</style>
<mv-butterfly id="bf-app">
<div class="bf-layout">
<div>
<div class="bf-card bf-settings" role="group" aria-labelledby="bf-settings-title">
<div>
<h3 id="bf-settings-title">Pricing</h3>
<p class="bf-sub">Aurora Health · Website redesign</p>
</div>
<mv-number-field id="bf-rate" label="Hourly rate" value="150" min="60" max="400" step="5" format="currency" currency="USD" decimals="0"
data-affects="#bf-l1, #bf-l2, #bf-l3"></mv-number-field>
<div class="bf-field">
<div class="bf-row"><label for="bf-tax-rate">Sales tax</label><output for="bf-tax-rate" id="bf-tax-out">8.25%</output></div>
<input type="range" class="mv-slider" id="bf-tax-rate" min="0" max="15" step="0.25" value="8.25" data-affects="#bf-tax-label, #bf-tax">
</div>
<div class="bf-field">
<div class="bf-row"><label for="bf-discount">Volume discount</label><output for="bf-discount" id="bf-discount-out">10%</output></div>
<input type="range" class="mv-slider" id="bf-discount" min="0" max="30" step="1" value="10" data-affects="#bf-disc-label, #bf-disc">
</div>
<div class="bf-field">
<label class="bf-lbl" for="bf-deposit">Deposit on signing</label>
<select class="mv-select" id="bf-deposit" data-affects="#bf-dep-label, #bf-dep">
<option value="0">No deposit</option>
<option value="25">25%</option>
<option value="30" selected>30%</option>
<option value="50">50%</option>
</select>
</div>
<label class="mv-choice" data-control="end" style="font-size:.8125rem">
<input type="checkbox" role="switch" class="mv-switch" id="bf-round" data-affects="#bf-total">
<span class="mv-choice-text"><span class="mv-choice-title">Round totals</span><span class="mv-choice-description">To the whole dollar; lines keep cents</span></span>
</label>
</div>
<div class="bf-card bf-options">
<p class="bf-k">Demo options</p>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="bf-opt-preview" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Preview new values</span></span>
</label>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="bf-opt-links" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Connector hints</span></span>
</label>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="bf-opt-approval">
<span class="mv-choice-text"><span class="mv-choice-title">Finance approval</span><span class="mv-choice-description">Block changes that move the total by more than 10%</span></span>
</label>
<p class="bf-log" id="bf-log" aria-live="polite"><span class="mv-badge" data-variant="secondary">Idle</span><span>Hover or focus a setting to see what it ripples into.</span></p>
</div>
</div>
<article class="bf-card bf-doc" aria-label="Invoice INV-2048" tabindex="0">
<section aria-labelledby="bf-s-invoice">
<div class="bf-inv-head">
<div>
<h3 id="bf-s-invoice">Invoice</h3>
<p class="bf-meta">INV-2048 · Aurora Health, Chicago<br>Issued Sep 23, 2026 · Due Nov 14, 2026</p>
</div>
<p class="bf-due"><span>Amount due</span><strong id="bf-due" data-butterfly-label="Amount due">$16,367.40</strong></p>
</div>
<div class="mv-table-wrap">
<table class="mv-table">
<thead><tr><th>Item</th><th data-align="end">Hours</th><th data-align="end">Amount</th></tr></thead>
<tbody>
<tr><th scope="row" style="font-weight:500">Discovery & design sprint</th><td data-align="end">32</td><td data-align="end" id="bf-l1" data-affects="#bf-subtotal">$4,800.00</td></tr>
<tr><th scope="row" style="font-weight:500">Frontend build</th><td data-align="end">64</td><td data-align="end" id="bf-l2" data-affects="#bf-subtotal">$9,600.00</td></tr>
<tr><th scope="row" style="font-weight:500">QA & launch support</th><td data-align="end">16</td><td data-align="end" id="bf-l3" data-affects="#bf-subtotal">$2,400.00</td></tr>
</tbody>
</table>
</div>
<dl class="bf-totals">
<dt>Subtotal</dt><dd id="bf-subtotal" data-affects="#bf-disc, #bf-tax, #bf-total">$16,800.00</dd>
<dt id="bf-disc-label">Volume discount (10%)</dt><dd id="bf-disc" data-affects="#bf-tax, #bf-total">−$1,680.00</dd>
<dt id="bf-tax-label">Sales tax (8.25%)</dt><dd id="bf-tax" data-affects="#bf-total">$1,247.40</dd>
<dt class="bf-grand">Total</dt><dd class="bf-grand" id="bf-total" data-affects="#bf-due, #bf-dep, #bf-bal">$16,367.40</dd>
</dl>
</section>
<section aria-labelledby="bf-s-schedule">
<h4 id="bf-s-schedule">Payment schedule</h4>
<dl class="bf-list">
<dt><span id="bf-dep-label">Deposit (30%)</span><small>On signing · Oct 1, 2026</small></dt><dd id="bf-dep" data-affects="#bf-bal" data-butterfly-label="Deposit">$4,910.22</dd>
<dt>Balance<small>On delivery · Nov 14, 2026</small></dt><dd id="bf-bal" data-butterfly-label="Balance">$11,457.18</dd>
</dl>
</section>
<section aria-labelledby="bf-s-forecast">
<h4 id="bf-s-forecast">Q4 forecast</h4>
<dl class="bf-list">
<dt>Invoiced revenue<small>All Q4 invoices</small></dt><dd id="bf-q-rev" data-butterfly-label="Invoiced revenue">$54,767.40</dd>
<dt>Sales tax to remit<small>Due Jan 20, 2027</small></dt><dd id="bf-q-tax" data-butterfly-label="Sales tax to remit">$4,415.40</dd>
<dt>Net revenue<small>After discounts</small></dt><dd id="bf-q-net" data-butterfly-label="Net revenue">$50,600.00</dd>
<dt>Project margin<small>At a $62 blended cost per hour</small></dt><dd id="bf-q-margin" data-butterfly-label="Project margin">54.1%</dd>
</dl>
</section>
</article>
</div>
</mv-butterfly>
<p class="bf-hint">Hover or focus a setting: every value it ripples into lights up, hop by hop · drag Sales tax to preview the new values · Alt+J jumps to the ones out of view</p>
<script type="module">
const bf = document.getElementById("bf-app");
const $ = (id) => document.getElementById(id);
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const usd0 = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const pct = (v) => `${Number(v.toFixed(2))}%`;
const HOURS = [32, 64, 16];
const COST = 62;
const OTHER = { revenue: 38400, tax: 3168, net: 35480 }; // the other Q4 invoices
const state = { rate: 150, tax: 8.25, discount: 10, deposit: 30, round: false };
const fields = { "bf-rate": "rate", "bf-tax-rate": "tax", "bf-discount": "discount", "bf-deposit": "deposit", "bf-round": "round" };
function model(s) {
const lines = HOURS.map((h) => h * s.rate);
const subtotal = lines.reduce((a, b) => a + b, 0);
const discount = subtotal * s.discount / 100;
const taxable = subtotal - discount;
const tax = taxable * s.tax / 100;
const total = s.round ? Math.round(taxable + tax) : taxable + tax;
const money = s.round ? usd0 : usd;
const deposit = total * s.deposit / 100;
const margin = (taxable - COST * HOURS.reduce((a, b) => a + b, 0)) / taxable * 100;
return {
total,
values: {
"#bf-l1": usd.format(lines[0]), "#bf-l2": usd.format(lines[1]), "#bf-l3": usd.format(lines[2]),
"#bf-subtotal": usd.format(subtotal),
"#bf-disc-label": `Volume discount (${pct(s.discount)})`, "#bf-disc": `−${usd.format(discount)}`,
"#bf-tax-label": `Sales tax (${pct(s.tax)})`, "#bf-tax": usd.format(tax),
"#bf-total": money.format(total), "#bf-due": money.format(total),
"#bf-dep-label": s.deposit ? `Deposit (${s.deposit}%)` : "No deposit",
"#bf-dep": money.format(deposit), "#bf-bal": money.format(total - deposit),
"#bf-q-rev": usd.format(OTHER.revenue + total), "#bf-q-tax": usd.format(OTHER.tax + tax),
"#bf-q-net": usd.format(OTHER.net + taxable), "#bf-q-margin": `${margin.toFixed(1)}%`,
},
};
}
const render = (values) => { for (const [sel, text] of Object.entries(values)) document.querySelector(`#bf-demo ${sel}`).textContent = text; };
const withChange = (control, value) => {
const key = fields[control.id];
const next = { ...state };
if (key === "round") next.round = Boolean(value);
else next[key] = Number(value);
return next;
};
// The report is rendered by another module: its links come from the graph property.
bf.graph = {
"#bf-total": "#bf-q-rev",
"#bf-tax": "#bf-q-tax",
"#bf-subtotal": "#bf-q-net",
"#bf-disc": "#bf-q-net",
"#bf-q-net": "#bf-q-margin",
};
const compute = ({ control, value }) => model(withChange(control, value)).values;
bf.compute = compute;
// Live labels next to the sliders (also receive the restored value after a veto).
for (const [id, out] of [["bf-tax-rate", "bf-tax-out"], ["bf-discount", "bf-discount-out"]]) {
$(id).addEventListener("input", (e) => { $(out).textContent = pct(Number(e.target.value)); });
}
const log = (variant, badge, text) => {
const b = document.createElement("span");
b.className = "mv-badge";
b.dataset.variant = variant;
b.textContent = badge;
const t = document.createElement("span");
t.textContent = text;
$("bf-log").replaceChildren(b, t);
};
const show = (key, v) => key === "round" ? (v ? "on" : "off") : key === "rate" ? usd0.format(v) : key === "deposit" ? `${v}%` : pct(v);
bf.addEventListener("mv-commit", (e) => {
const { control, value, impact } = e.detail;
const key = fields[control.id];
if (!key) return;
const next = withChange(control, value);
const before = model(state).total;
const after = model(next).total;
const delta = before ? (after - before) / before * 100 : 0;
if ($("bf-opt-approval").checked && Math.abs(delta) > 10) {
e.preventDefault();
log("danger", "Blocked", `${impact.label} ${show(key, state[key])} → ${show(key, next[key])} would move the total by ${delta > 0 ? "+" : ""}${delta.toFixed(1)}%. Needs finance approval.`);
return;
}
const changed = Object.entries(model(next).values).filter(([sel, v]) => v !== model(state).values[sel]).length;
Object.assign(state, next);
render(model(state).values);
log("success", "Applied", `${impact.label} ${show(key, state[key])} · ${changed} of ${impact.count} linked values updated`);
});
bf.addEventListener("mv-impact", (e) => {
if (e.detail.reason === "api") return;
const { label, count, sections } = e.detail;
log("info", "Impact", `${label} affects ${count} values across ${sections.length} section${sections.length === 1 ? "" : "s"}.`);
});
$("bf-opt-preview").addEventListener("change", (e) => { bf.compute = e.target.checked ? compute : null; });
$("bf-opt-links").addEventListener("change", (e) => { bf.connectors = e.target.checked; });
// Start by showing what the hourly rate ripples into.
await customElements.whenDefined("mv-butterfly");
requestAnimationFrame(() => bf.show("#bf-rate"));
</script>
</div>Cultural reference
A Sound of Thunder — Ray Bradbury (1952, book). A time traveler steps off the levitating path and crushes a butterfly, and that tiny cause cascades through the whole future he returns to. In the UI, before a value is changed, every value it will ripple into lights up hop by hop, with before → after ghosts, so the consequences are seen before the step is taken.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
data-affects | CSS selector list | On any element inside the region (control) or anywhere in the scope (dependent): the elements a change to it affects. Dependents may carry their own data-affects: the impact is followed transitively, breadth-first, and cycles are cut. An element with dependents that is, or holds, a focusable control (or a custom control with a value) is a source. | |
data-butterfly-label | string | On a source or dependent: name used in the summary, the jump list, descriptions and events. Default: aria-label, label attribute (custom controls), aria-labelledby, <label>, the <dt> of a <dd>, “row header · column header” for table cells, then the text. | |
data-butterfly-section | string | On an ancestor: section name used in counts (“across 3 sections”). Default: the nearest fieldset legend or labelled / headed section, article, aside, form, region or group. | |
scope | CSS selector | document | Root in which dependents are looked up and watched. Sources are always inside the element. |
depth | number | 12 | Maximum number of hops followed from a source (1 = direct dependents only). |
trigger | "hover" | "focus" | "hover focus" | hover focus | What shows the impact of a source. Editing a source always shows it, and show() works regardless. |
hover-delay | number (ms) | 120 | Hover intent before the impact appears (moving from one source to the next follows in 60 ms). |
hop | number (ms) | 110 | Ripple delay per order: direct dependents land after one hop, second-order ones after two…; within an order the nearest land first. |
connectors | "true" | "false" | true | Faint curves from each dependent to what it depends on (the source for direct ones). |
summary | "true" | "false" | true | The floating summary with counts and the off-screen jump list. "false" keeps rings, badges, ghosts and descriptions only. |
preview-events | event types | input mv-input | Events that mean “the value is being edited” (starts a preview). Native events from inside a custom control are ignored: its own mv-* events speak for it. |
commit-events | event types | change mv-change | Events that commit a change (observed in the capture phase so a veto can stop them). |
data-butterfly | "source" | "affected" | Set by the component on the active source and on each dependent while shown (styleable); dependents also get data-butterfly-order="n". |
Properties
| Name | Type | Description |
|---|---|---|
graph | Record<selector, selector | selector[]> | Map<Element | selector, (Element | selector)[]> | Extra edges, merged with data-affects (e.g. generated from a spreadsheet’s formulas or a report rendered by another module). Setting it refreshes descriptions and the impact shown. |
compute | (change) => Map | Record<selector, value> | [target, value][] | Promise | Enables the preview. Called once per frame while a source is edited with { source, control, value, previous, impact }; return the new display value of any dependent (element or selector keys). Only dependents of the edited source are shown; async results are latest-wins. |
active | { source, reason, label, count, sections, orders, depth, items } | null | The impact currently shown (read-only). reason: hover | focus | input | api. items: { element, order, parent, label, section, value }. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement ({placeholders}); English defaults. |
connectors / summary / scope / depth / trigger / hop / hoverDelay / previewEvents / commitEvents | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
impactOf(source) | Computes the impact of a source (element or selector) without showing anything: { source, label, count, sections, orders, depth, items }. Useful for a confirmation message or analytics. |
show(source) | Shows the impact of a source from app code (onboarding, a “what does this affect?” link) until the user hovers or focuses another source, presses Escape or hide() is called. Returns false if it has no dependents. |
hide() | Hides the impact (and ends a preview without restoring the value). |
discard() | Ends the current preview and restores the control’s last committed value (mv-revert with reason "api"). Returns false when nothing is previewed. |
refresh() | Re-reads data-affects and the graph. Rarely needed: mutations inside the scope are watched. |
Events
| Name | Description |
|---|---|
mv-impact | An impact is shown. Cancelable: preventDefault() skips the visuals (render your own). detail: { reason, source, label, count, sections, orders, depth, items }. |
mv-impact-end | The impact was hidden. detail: { source, reason: "leave" | "replaced" | "disconnect" }. |
mv-preview | compute() results were applied. detail: { source, control, value, previous, changes: [{ element, order, label, before, after, trend: "up" | "down" | "changed" | "same" }] }. |
mv-commit | A source’s change is being committed (before the app’s own change handlers). Cancelable: preventDefault() vetoes it, the control gets its previous value back (an input event is re-dispatched on native fields) and the original change event is stopped. detail: { source, control, value, previous, impact, changes (null without a preview) }. |
mv-revert | A control was restored. detail: { source, control, value (restored), attempted, reason: "veto" | "escape" | "api" }. |
Content structure
| Name | Description |
|---|---|
(content) | Any region with controls (native fields, mv-slider, mv-number-field, custom controls with a value). Dependents can be anywhere in scope, including scroll containers: rings are clipped to them and the ones hidden are listed in the summary. |
CSS classes
| Name | Description |
|---|---|
mv-butterfly-layer | Top-layer overlay (popover, aria-hidden, pointer-events: none) holding rings, badges, ghosts and connectors; data-animate while the ripple plays. |
mv-butterfly-ring / -badge / -ghost / -origin | Per dependent: ring (data-order 1 | 2 | 3 = 3+, data-delta during a preview, data-flash), order badge, before → after ghost (-before, -arrow, -after, -trend); origin = ring and pulse around the source. |
mv-butterfly-links / -link | SVG connectors, one path per dependent (data-order). |
mv-butterfly-panel | Floating summary (popover, role=group): -head, -icon, -title, -count, -orders, -sections, -preview, -offscreen (-k, -list, -jump with -jump-label, -jump-section, -jump-value), -hint. data-reason mirrors the trigger. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-butterfly-color | var(--mv-accent) | Tint of rings, badges and connectors; deeper orders mix it toward the muted text color. |
--mv-butterfly-panel-width | 18.5rem | Width of the floating summary. |
Accessibility
Every source control gets a description through aria-describedby (a hidden element maintained by the component): “Changing Tax rate affects 8 values across 3 sections: Invoice, Payment schedule and Q4 forecast. Alt+J jumps to the ones out of view.”, so screen reader users hear the impact on focus, without any chatter. Focus shows the same rings as hover; Alt+J (never with Ctrl, so AltGr layouts are safe) moves focus into the jump list, arrow keys, Home and End move within it, Enter scrolls the dependent into view, flashes its ring and announces its value politely, Escape or Alt+J returns to the control. Escape on a control hides the impact without swallowing the key (an enclosing dialog still closes), and during a preview it discards the edit and restores the value. Previews are summarized politely once the user pauses (“11 of 14 linked values will change.”), commits and vetoes are announced. The overlay is aria-hidden with pointer-events: none and lives in the top layer, so the page is never covered for input; the summary moves out of the way of the control and its dependents. Orders are distinguished by badge number and line style (solid, dashed, dotted), never by color alone. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no ripple, pulse or drawing, all highlights appear at once and jumps scroll instantly. Forced colors: rings, badges and connectors use Highlight, ghosts Canvas / CanvasText.