Exclusivobeta

Butterfly <mv-butterfly>

Vista previa del impacto para ajustes, formularios de precios, editores y pantallas tipo hoja de cálculo: antes de cambiar un valor, se iluminan todos los valores a los que se propagará. Los controles declaran lo que afectan con data-affects="#invoice-total, .tax-line" (o una propiedad graph), los dependientes pueden declarar sus propios dependientes, y el componente recorre la cadena de forma transitiva, ciclos incluidos. Al pasar el cursor o enfocar un control, cada dependiente de cualquier parte de la página se rodea con un anillo y una insignia de orden (1 = directo, 2 = a través de un intermediario…) y un estilo de línea por orden, la onda se propaga salto a salto por conectores tenues, y un resumen flotante que evita tapar lo que describe lo cuenta todo (“Affects 14 values · 3 sections”) y enumera los dependientes ocultos en zonas con desplazamiento o fuera de pantalla, con enlaces de salto. Dale compute(change) y la edición se convierte en una vista previa: cada dependiente muestra un fantasma antes → después sobre su propio valor hasta que se confirma el cambio; mv-commit es cancelable y un veto restaura el control y nunca llega a los handlers de change de la app. Las etiquetas salen de aria, <label>, <dt> y los encabezados de fila y columna de las tablas; las secciones, de los encabezados, las leyendas y los aria-labels.

CategoríaVisualización de datos
TipoWeb Component (<mv-butterfly>)
Estadobeta
KitAcciones destructivas y secretos seguros
Keywordsexclusive, 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

Instalación

node scripts/add.mjs butterfly --out ./src/marvelous

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["butterfly"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): 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.

Uso

Inicio rápido, el marcado mínimo que funciona:

<mv-butterfly>
  <label>Tax rate (%) <input type="number" value="8" data-affects="#tax"></label>
  <p>Tax <output id="tax" data-affects="#total">$80</output> · Total <output id="total">$1,080</output></p>
</mv-butterfly>

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:

<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 &amp; 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 &amp; 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>

Referencia cultural

El ruido de un trueno, Ray Bradbury (1952, libro). Un viajero en el tiempo se sale del sendero levitante y aplasta una mariposa, y esa causa diminuta se propaga en cascada por todo el futuro al que regresa. En la interfaz, antes de cambiar un valor, cada valor al que se propagará se ilumina salto a salto, con fantasmas antes → después, para ver las consecuencias antes de dar el paso.

API

Attributes

NameTipoDefaultDescription
data-affectsCSS selector listOn 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-labelstringOn 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-sectionstringOn 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.
scopeCSS selectordocumentRoot in which dependents are looked up and watched. Sources are always inside the element.
depthnumber12Maximum number of hops followed from a source (1 = direct dependents only).
trigger"hover" | "focus" | "hover focus"hover focusWhat shows the impact of a source. Editing a source always shows it, and show() works regardless.
hover-delaynumber (ms)120Hover intent before the impact appears (moving from one source to the next follows in 60 ms).
hopnumber (ms)110Ripple delay per order: direct dependents land after one hop, second-order ones after two…; within an order the nearest land first.
connectors"true" | "false"trueFaint curves from each dependent to what it depends on (the source for direct ones).
summary"true" | "false"trueThe floating summary with counts and the off-screen jump list. "false" keeps rings, badges, ghosts and descriptions only.
preview-eventsevent typesinput mv-inputEvents 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-eventsevent typeschange mv-changeEvents 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

NameTipoDescription
graphRecord<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][] | PromiseEnables 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 } | nullThe impact currently shown (read-only). reason: hover | focus | input | api. items: { element, order, parent, label, section, value }.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement ({placeholders}); English defaults.
connectors / summary / scope / depth / trigger / hop / hoverDelay / previewEvents / commitEventsreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-impactAn impact is shown. Cancelable: preventDefault() skips the visuals (render your own). detail: { reason, source, label, count, sections, orders, depth, items }.
mv-impact-endThe impact was hidden. detail: { source, reason: "leave" | "replaced" | "disconnect" }.
mv-previewcompute() results were applied. detail: { source, control, value, previous, changes: [{ element, order, label, before, after, trend: "up" | "down" | "changed" | "same" }] }.
mv-commitA 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-revertA control was restored. detail: { source, control, value (restored), attempted, reason: "veto" | "escape" | "api" }.

Content structure

NameDescription
(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

NameDescription
mv-butterfly-layerTop-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 / -originPer 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 / -linkSVG connectors, one path per dependent (data-order).
mv-butterfly-panelFloating 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

NameDefaultDescription
--mv-butterfly-colorvar(--mv-accent)Tint of rings, badges and connectors; deeper orders mix it toward the muted text color.
--mv-butterfly-panel-width18.5remWidth 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.

Esta página se tradujo con IA. Informar de un problema de traducción