Exclusivebeta

Wobble <mv-wobble>

Removal impact preview that scales the friction of a delete, archive, disable, revoke or unpublish to what really depends on the item. Wrap any list, table or settings page: hovering or focusing an item’s destructive action “tests” it through your async probe(id) (or a data-wobble-report JSON attribute), with hover intent, in-flight dedupe, a cache window and a timeout, and an anchored card classifies the removal. Loose: nothing depends on it, it goes at once with an in-place undo bar (Ctrl/Cmd+Z, the window pauses on hover or focus). Load-bearing: things break or change, listed by kind with counts and samples (“3 automations stop running · 12 invoices lose their region · 1 dashboard filter breaks”), the item gives a subtle wobble and activating it opens a focused alertdialog that lists every consequence, with an optional acknowledgment checkbox above a threshold. Foundational: removal is blocked, with the reason, next-step links and a “Reassign to…” choice whose cancelable mv-reassign (with waitUntil) moves the load before the item is tested again. A tested item remembers its tier (data-wobble-tier, optional [data-wobble-mark] chips, the trigger’s accessible description), and survey tests every visible item up front so people learn which pieces hold everything up. mv-remove is cancelable with waitUntil, mv-commit tells when a removal is final.

CategoryFeedback
TypeWeb Component (<mv-wobble>)
Statusbeta
KitSafe destructive actions and secrets
Also installsbutton, checkbox, select
Keywordsexclusive, culture, delete, remove, archive, revoke, impact, dependencies, blast-radius, confirm, undo, reassign, destructive-action, safe-delete, admin, settings, alertdialog

When to use

  • Deleting a tag, custom field, role or category may silently break automations, reports or records that use it
  • Admins revoke API keys, remove team members or disable feature flags and must see what stops working before they do
  • Most deletions in a list are harmless and should stay one click, but a few need a real confirmation or are blocked
  • An entity cannot be removed while others depend on it, and people should be offered to reassign them in place

Avoid when

  • The change is a value edit whose effects on other values in the page should be previewed, not a removal → use Butterfly instead
  • Every removal needs the same confirmation regardless of what depends on it, and there is no dependency data → use Dialog instead
  • The action is critical and must be armed by two people or two deliberate keys, not just informed by its impact → use Two-Key instead

Install

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

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["wobble"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/position.js, components/wobble/wobble.js, components/wobble/wobble.css, components/button/button.css, components/checkbox/checkbox.css, components/select/select.css.

Usage

Quick start, the smallest working markup:

<mv-wobble verb="delete">
  <p data-wobble-id="vip" data-wobble-name="VIP" data-wobble-report='{"dependents":[{"count":3,"noun":"automation|automations","effect":"stop running","impact":"breaks"}]}'>VIP <button data-wobble-remove>Delete</button></p>
  <p data-wobble-id="old" data-wobble-name="Old leads" data-wobble-report='{"dependents":[]}'>Old leads <button data-wobble-remove>Delete</button></p>
</mv-wobble>

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

<div id="wb-demo" style="width:min(100%,46rem);margin-inline:auto">
  <style>
    #wb-demo { display:grid; gap:1.25rem; align-content:start }
    #wb-demo .wb-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #wb-demo .wb-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.125rem .875rem; border-bottom:1px solid var(--mv-border) }
    #wb-demo .wb-head h4 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #wb-demo .wb-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #wb-demo .wb-list { margin:0; padding:.375rem; list-style:none; display:grid; gap:.125rem }
    #wb-demo .wb-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto auto; align-items:center; gap:.75rem; padding:.5rem .5rem .5rem .75rem; border-radius:var(--mv-radius-md) }
    #wb-demo .wb-row:hover { background-color:var(--mv-bg-subtle) }
    #wb-demo .wb-dot { width:.625rem; height:.625rem; border-radius:50%; background:var(--c) }
    #wb-demo .wb-key { display:grid; place-items:center; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
    #wb-demo .wb-key svg { width:1rem; height:1rem }
    #wb-demo .wb-name { display:grid; gap:.0625rem; min-width:0 }
    #wb-demo .wb-name strong { font-size:.875rem; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #wb-demo .wb-name span { color:var(--mv-fg-muted); font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #wb-demo .wb-name code { font:.75rem var(--mv-font-mono) }
    #wb-demo .wb-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) }
    #wb-demo .wb-controls { display:flex; align-items:center; gap:.5rem 1.25rem; flex-wrap:wrap }
    #wb-demo .wb-controls .mv-choice { font-size:.8125rem }
    #wb-demo .wb-log { margin:0; min-height:1.1rem; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono); overflow-wrap:anywhere }
    #wb-demo mv-wobble { padding-bottom:.25rem }
    @media (max-width:36rem) {
      #wb-demo .wb-row { grid-template-columns:auto minmax(0,1fr) auto; }
      #wb-demo .wb-row [data-wobble-mark] { grid-column:2; grid-row:2; justify-self:start }
    }
  </style>

  <!-- Main: workspace labels, probed through an async callback; survey tests every visible label up front -->
  <section class="wb-card" aria-labelledby="wb-labels-title">
    <div class="wb-head">
      <div>
        <h4 id="wb-labels-title">Labels</h4>
        <p>Used on tickets, invoices, automations and reports. Hover or focus “Delete” to see what depends on a label.</p>
      </div>
      <span class="mv-badge" data-variant="secondary">6 labels</span>
    </div>

    <mv-wobble id="wb-labels" verb="delete" survey>
      <ul class="wb-list">
        <li class="wb-row" data-wobble-id="emea" data-wobble-name="Region: EMEA">
          <span class="wb-dot" style="--c:oklch(0.62 0.15 250)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Region: EMEA</strong><span>Europe, Middle East and Africa accounts</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Region: EMEA">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="legacy" data-wobble-name="Legacy import 2023">
          <span class="wb-dot" style="--c:oklch(0.7 0.02 260)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Legacy import 2023</strong><span>Created by the CSV migration, Mar 14, 2023</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Legacy import 2023">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="billing" data-wobble-name="Billing contact">
          <span class="wb-dot" style="--c:oklch(0.62 0.17 25)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Billing contact</strong><span>Marks who receives invoices on each account</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Billing contact">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="vip" data-wobble-name="VIP">
          <span class="wb-dot" style="--c:oklch(0.72 0.15 80)" aria-hidden="true"></span>
          <span class="wb-name"><strong>VIP</strong><span>Accounts above $50,000 ARR</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete VIP">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="webinar" data-wobble-name="Q2 webinar leads">
          <span class="wb-dot" style="--c:oklch(0.66 0.14 160)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Q2 webinar leads</strong><span>Imported from the June 12 registration list</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Q2 webinar leads">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="churn" data-wobble-name="Churn risk">
          <span class="wb-dot" style="--c:oklch(0.6 0.16 320)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Churn risk</strong><span>Set by the health score below 40</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Churn risk">Delete</button>
        </li>
      </ul>
    </mv-wobble>

    <div class="wb-foot">
      <span class="wb-controls">
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wb-ack"> Require acknowledgment above 10 affected</label>
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wb-slow"> Slow server</label>
      </span>
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="wb-reset">Reset demo</button>
      <p class="wb-log" id="wb-log" aria-live="polite">Testing every label…</p>
    </div>
  </section>

  <!-- Markup only: API keys, reports in data-wobble-report, verb "revoke"; marks appear as keys get tested -->
  <section class="wb-card" aria-labelledby="wb-keys-title">
    <div class="wb-head">
      <div>
        <h4 id="wb-keys-title">API keys</h4>
        <p>Reports come from the markup, no script. Each key remembers its tier once tested.</p>
      </div>
    </div>
    <mv-wobble id="wb-keys" verb="revoke">
      <ul class="wb-list">
        <li class="wb-row" data-wobble-id="key-staging" data-wobble-name="Staging CI"
            data-wobble-report='{"dependents":[]}'>
          <span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
          <span class="wb-name"><strong>Staging CI</strong><span><code>sk_test_…a91f</code> · last used 94 days ago</span></span>
          <span data-wobble-mark="icon"></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Staging CI">Revoke</button>
        </li>
        <li class="wb-row" data-wobble-id="key-zapier" data-wobble-name="Zapier integration"
            data-wobble-report='{"dependents":[{"count":4,"noun":"zap|zaps","effect":"stop running","impact":"breaks","items":["New invoice → Slack","Failed payment → Asana","Weekly MRR sheet","Refund → Zendesk"]},{"count":1,"noun":"scheduled export|scheduled exports","effect":"fails on Monday","impact":"breaks","items":["Finance export (Mon 6:00 AM)"]}]}'>
          <span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
          <span class="wb-name"><strong>Zapier integration</strong><span><code>sk_live_…3c7d</code> · used 2 minutes ago</span></span>
          <span data-wobble-mark="icon"></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Zapier integration">Revoke</button>
        </li>
        <li class="wb-row" data-wobble-id="key-webhook" data-wobble-name="Production webhook signer"
            data-wobble-report='{"reason":"Live webhooks sign every event with this key. Move them to another signing key first.","dependents":[{"count":2,"noun":"live webhook|live webhooks","effect":"can’t verify events","impact":"blocks","items":["orders.created → fulfillment","payments.failed → dunning"]}],"next":[{"label":"Create a new signing key","href":"#keys-new"},{"label":"Open webhook settings","href":"#webhooks"}]}'>
          <span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
          <span class="wb-name"><strong>Production webhook signer</strong><span><code>whsec_…e20b</code> · signs 1,284 events a day</span></span>
          <span data-wobble-mark="icon"></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Production webhook signer">Revoke</button>
        </li>
      </ul>
    </mv-wobble>
  </section>

  <script type="module">
    const labels = document.getElementById("wb-labels");
    const log = document.getElementById("wb-log");
    const slow = document.getElementById("wb-slow");
    const say = (text) => { log.textContent = text; };

    // What the server knows about each label (a real app would query its dependency graph).
    const initial = () => ({
      emea: { dependents: [
        { kind: "automation", count: 3, noun: "automation|automations", effect: "stop running", impact: "breaks", items: ["EMEA ticket routing", "VAT reminder", "Weekly EMEA digest"], href: "#automations" },
        { kind: "invoice", count: 12, noun: "invoice|invoices", effect: "lose their region", impact: "changes", items: ["INV-2041 · Northwind", "INV-2038 · Contoso"] },
        { kind: "filter", count: 1, noun: "dashboard filter|dashboard filters", effect: "breaks", impact: "breaks", items: ["Revenue by region"], href: "#dashboards" },
      ] },
      legacy: { dependents: [] },
      billing: {
        reason: "Every active contract needs a label that marks who receives its invoices.",
        dependents: [
          { kind: "contract", count: 3, noun: "active contract|active contracts", effect: "require it", impact: "blocks", items: ["Northwind Traders", "Fabrikam", "Tailspin Toys"] },
          { kind: "contact", count: 11, noun: "contact|contacts", effect: "carry it", impact: "changes" },
        ],
        reassign: { label: "Reassign its contracts and contacts to", targets: [{ value: "finance", label: "Finance contact" }, { value: "primary", label: "Primary contact" }] },
      },
      vip: { dependents: [
        { kind: "contact", count: 48, noun: "account|accounts", effect: "lose the VIP label", impact: "changes", items: ["Adatum Corporation", "Litware", "Woodgrove Bank"] },
        { kind: "view", count: 2, noun: "saved view|saved views", effect: "come back empty", impact: "breaks", items: ["VIP renewals", "High-touch accounts"] },
      ] },
      webinar: { dependents: [] },
      churn: { dependents: [
        { kind: "automation", count: 1, noun: "automation|automations", effect: "stops running", impact: "breaks", items: ["Save-desk alert"] },
        { kind: "account", count: 7, noun: "account|accounts", effect: "lose the label", impact: "changes" },
      ] },
    });
    let graph = initial();
    const wait = (ms) => new Promise((r) => setTimeout(r, ms));

    labels.probe = async (id) => {
      await wait(slow.checked ? 1600 : 280 + (id.length % 4) * 90);
      return structuredClone(graph[id]);
    };

    let tested = 0;
    labels.addEventListener("mv-probe", (e) => {
      tested++;
      if (tested === 6) say("All 6 labels tested: 2 loose, 3 load-bearing, 1 foundational.");
    });
    labels.addEventListener("mv-remove", (e) => {
      const { id, tier } = e.detail;
      say(`mv-remove · ${id} (${tier})`);
      // A real app would call its API here: e.detail.waitUntil(fetch(…, { method: "DELETE" }))
      if (tier !== "loose") e.detail.waitUntil(wait(slow.checked ? 1200 : 450));
    });
    labels.addEventListener("mv-undo", (e) => say(`mv-undo · ${e.detail.id} restored`));
    labels.addEventListener("mv-commit", (e) => say(`mv-commit · ${e.detail.id} deleted for good`));
    labels.addEventListener("mv-reassign", (e) => {
      const { id, to, toLabel } = e.detail;
      say(`mv-reassign · ${id} → ${to}`);
      // Once the contacts are moved and the contracts point to “${toLabel}”, nothing depends on the label anymore.
      e.detail.waitUntil(wait(slow.checked ? 1400 : 600).then(() => { graph[id] = { dependents: [] }; }));
    });

    document.getElementById("wb-ack").addEventListener("change", (e) => {
      if (e.target.checked) labels.setAttribute("acknowledge-at", "10");
      else labels.removeAttribute("acknowledge-at");
    });
    document.getElementById("wb-reset").addEventListener("click", () => {
      labels.close();
      graph = initial();
      tested = 0;
      labels.invalidate();
      for (const item of labels.querySelectorAll("[data-wobble-id]")) {
        item.hidden = false;
        item.removeAttribute("data-wobble-removed");
        item.removeAttribute("data-wobble-tier");
        item.querySelector("[data-wobble-mark]").replaceChildren();
      }
      labels.survey = false;
      labels.survey = true;
      say("Testing every label…");
    });

    const keys = document.getElementById("wb-keys");
    keys.addEventListener("mv-commit", (e) => {
      // Markup demo: keys come back after a while so the example stays usable.
      setTimeout(() => { e.detail.item.hidden = false; e.detail.item.removeAttribute("data-wobble-removed"); }, 2500);
    });
  </script>
</div>

Cultural reference

Jenga, Leslie Scott (1983, game). Before pulling a block from the tower, players test it with a finger: a loose block slides out freely, a load-bearing one makes the tower wobble, and over the game everyone learns which pieces hold everything up. In the UI, hovering a destructive action tests the item against what depends on it, and the removal slides through with undo, asks for a confirm listing what breaks, or is blocked until the load is reassigned, while each tested item keeps showing how much it holds up.

API

Attributes

NameTypeDefaultDescription
triggerCSS selector[data-wobble-remove]Destructive actions inside the element that test and remove an item. Disabled ones (:disabled, aria-disabled="true", inside [inert]) are ignored. Their default action (link, submit) is prevented: the component decides.
verbstring ("delete" | "archive" | "revoke|revoked"…)removeVerb used in every text (“Delete “VIP”?”, “Revoke anyway”, “Staging CI revoked”). The past tense is derived (adds d/ed) or given after a pipe. Per item: data-wobble-verb on the item or the trigger.
cachetime ("30s", "2m", "0")30sHow long a probe result is reused for hovers and clicks. It bounds how stale the preview can be; your server must still refuse removals that became unsafe.
undotime5sUndo window of a loose removal before mv-commit. 0 commits at once. A new removal commits the pending one.
removalhide | remove | nonehideWhat happens to the item once removed: hide collapses it and sets hidden (restored by undo), remove also takes it out of the DOM at commit, none leaves the DOM to your app (frameworks that own the list).
acknowledge-atnumberWhen a load-bearing or unverified removal affects at least this many things, the confirm requires checking “I understand N items will be affected” before the destructive button works.
probe-delaynumber (ms)150Hover intent before a pointer hover probes an item (fly-overs don’t call your API). Focus probes immediately; moving from one trigger to the next while the card is open switches after 60 ms.
placementtop | bottom | left | right (-start | -end)bottom-endPreferred side of the card relative to the trigger; flips and shifts to stay in the viewport.
surveybooleanTests every trigger as it scrolls into view (IntersectionObserver), so marks and descriptions show the whole structure before anyone hovers. Calls probe once per item: batch on your side if needed.
data-wobble-id / data-wobble-namestringOn the item (any ancestor of the trigger): its id passed to probe and events, and the name used in texts. Without an item, data-wobble-remove="id" on the trigger itself works too.
data-wobble-reportJSON reportOn the item: a report written in the markup (same shape as probe’s result). Takes precedence over probe; changing it invalidates the cache.
data-wobble-mark"" | "icon"An empty element inside the item that the component fills with a tier chip once the item is tested (icon, name and count; icon only with "icon"). It is aria-hidden: the tier reaches assistive tech through the trigger’s description.
data-wobble-tier / data-wobble-active / data-wobble-removedset by the componentOn items: the last known tier (loose | load-bearing | foundational | unknown), the item currently being tested (tinted), and a removed item waiting for its undo window.

Properties

NameTypeDescription
probe(id, { item, trigger, signal }) => Report | Promise<Report>Returns what depends on the item: { dependents: [{ count, noun: "invoice|invoices", effect: "lose their region", impact: "breaks" | "changes" | "blocks", text?, items?: string[], href?, kind? }], tier?, blocked?, reason?, reassign?: { label?, targets: [{ value, label }] }, next?: [{ label, href }] }. The tier is derived when omitted: blocked or any "blocks" impact → foundational, any count → load-bearing, none → loose. A rejection, a timeout (10 s) or an unreadable result gives the unverified tier, which always asks for confirmation. Setting it clears the cache.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (tier names, titles, hints, buttons, undo, errors), with {name}, {Name}, {verb}, {Verb}, {past}, {total}, {things} placeholders. English defaults.
trigger / verb / cache / undo / removal / acknowledgeAt / probeDelay / placement / surveyreflectedMirror the attributes.

Methods

NameDescription
check(target, { fresh? })Tests an item (id, item or trigger) and resolves to its normalized report ({ tier, dependents, total, reason, reassign, next, raw }), or null if not found. fresh bypasses the cache.
invalidate(id?)Forgets the cached report of one item, or all of them (after a change made elsewhere).
preview(target)Opens the impact card for an item without moving focus (onboarding, a “What uses this?” link).
requestRemoval(target)Same as activating the item’s trigger: removes, confirms or blocks according to its tier.
undoRemoval()Undoes the pending loose removal. Returns true if an item was restored.
close()Closes the card (a pending confirm is cancelled).

Events

NameDescription
mv-probeA fresh report arrived (not fired for cache hits). detail: { id, item, trigger, tier, report }.
mv-removeCancelable, when the removal is decided: at once for loose items, after the confirm otherwise. detail: { id, item, trigger, tier, report, waitUntil(promise) }. preventDefault() vetoes it; waitUntil shows a busy state and a rejection keeps the item with an error. For loose items, prefer committing on mv-commit (undo stays possible until then).
mv-reassignCancelable, “Reassign” was chosen on a foundational item. detail: { id, item, trigger, to, toLabel, report, waitUntil(promise) }. A promise resolving to a report uses it as the new state; otherwise the item is probed again and the card switches to its new tier.
mv-undoA loose removal was undone within its window. detail: { id, item, trigger, report }.
mv-commitThe removal is final: right after mv-remove for confirmed removals, at the end of the undo window (or on pagehide, or when the element is removed) for loose ones. detail: { id, item, tier, report }.
mv-errorA probe, removal or reassignment failed. detail: { action: "probe" | "remove" | "reassign", id, error }.

Content structure

NameDescription
(content)Any list, table or layout of items with their destructive actions. The component appends its card (Popover API top layer), undo bar, hidden descriptions and live regions after your content, and only touches your DOM through data-wobble-* attributes, aria-describedby on triggers, [data-wobble-mark] contents and the hidden state of removed items (unless removal="none").

CSS classes

NameDescription
mv-wobble-cardThe impact card. data-mode="preview | confirm | blocked", data-tier="checking | loose | load-bearing | foundational | unknown". Parts: -head, -tier (-tier-icon, -tier-label), -scale (three rising bars filled up to the tier), -title, -text, -list, -dep (data-impact, -dep-icon, -dep-text, -dep-items, -dep-link), -next, -reassign, -ack, -error, -hint, -actions (-cancel, -confirm).
mv-wobble-undoSticky undo bar after a loose removal: -undo-icon, -undo-text, -undo-btn, -undo-meter (depleting line, data-paused while held).
mv-wobble-mark-icon / -mark-label / -mark-countParts written inside your [data-wobble-mark] elements (data-tier set on the mark).

CSS variables

NameDefaultDescription
--mv-wobble-loosevar(--mv-success)Tone of loose items (card, marks, undo bar).
--mv-wobble-load-bearingvar(--mv-warning)Tone of load-bearing items.
--mv-wobble-foundationalvar(--mv-danger)Tone of foundational items and of consequences that break or block.
--mv-wobble-amount1Strength of the item’s motion when tested (0 turns it off, up to 3): the wobble of load-bearing items and the small slide of loose ones. Foundational items never move.

Accessibility

Triggers stay your own buttons or links with their names; the component adds to each tested trigger an aria-describedby pointing to a hidden text that states the tier and every consequence (“Load-bearing. 16 items depend on “Region: EMEA”. 3 automations stop running; 12 invoices lose their region; 1 dashboard filter breaks.”). When a trigger receives keyboard focus, the result is also announced politely once the probe returns, so nobody has to hover. The preview card is a visual mirror (aria-hidden, nothing focusable) that never takes focus; it stays open while the pointer rests on it and Escape dismisses it (WCAG 1.4.13). Activating a load-bearing, unverified or foundational item opens the card as a labelled, described role="alertdialog" (aria-modal) with focus trapped inside: focus lands on Cancel (the least destructive action), or on the reassignment select when the removal is blocked, and blocked removals are announced assertively. Consequences are a real list; tiers are always stated in words next to an icon (a sliding block, a block under load, a lock), never by color alone, and the rising-bars scale is decorative. Escape or Cancel closes the confirm and returns focus to the trigger; an outside press cancels it. The acknowledgment is a native checkbox with a label; the destructive button is aria-disabled (still focusable) until it is checked and explains why if pressed. Pending actions set aria-busy on the button and the trigger; failures are announced assertively and shown in the card. After a removal, focus moves to the Undo button (loose) or to the next item’s trigger, never to the body; undo returns focus to the restored trigger, Ctrl/Cmd+Z works while the undo bar is shown, and the undo window pauses while the bar is hovered or focused. Reduced motion (OS or data-motion="reduce"): no wobble, no slide, no collapse or spinner animation; the tier text carries everything. Forced colors: marks, tier pill and scale get system-color borders and fills, the tested item gets a Highlight outline.