Exclusivebeta

Insist — <mv-insist>

Progressive hints that only appear when the user is stuck, and grow more direct the longer they struggle. Wrap a form, an onboarding card or an empty state, declare the goal (a selector clicked, changed or submitted, a done event, or complete()) and up to three levels: a soft glow on the target, then a small anchored coach mark, then a direct instruction with “Show me” that scrolls to and focuses the target (it never clicks for the user). Being stuck is measured, not guessed: time without progress (paused while the user types, the tab is hidden or the region is off screen), wrong actions (presses on disabled controls, validation errors, aria-invalid, rage clicks, going back and forth between the same controls), and hesitation (the pointer lingering near the target speeds the clock up). Each level waits for its delay, a wrong action counts as half a delay but never skips a level, and any real progress (a field turning valid, a new value, progress()) silences everything and restarts the clock. target="auto" follows the first field that still blocks the goal, with per-field texts (data-insist-hint, data-insist-instruction), and the halo flies to it; “I’ve got it” stops hints for good (remembered in localStorage with remember), Escape only steps the hint back one level, and a shared coordinator keeps a single hint on screen across every instance. mv-hint is cancelable so the app can veto or replace any level, and mv-goal reports how much help was needed.

CategoryFeedback
TypeWeb Component (<mv-insist>)
Statusbeta
Also installsbutton
Keywordsexclusive, culture, onboarding, hint, coach-mark, stuck, help, guidance, empty-state, form, validation, nudge, adaptive, progressive-disclosure, analytics, accessibility, spotlight

When to use

Avoid when

Install

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

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["insist"], 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, core/observe.js, core/position.js, components/insist/insist.js, components/insist/insist.css, components/button/button.css.

Usage

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

<div id="iq-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #iq-demo .iq-layout { display:grid; grid-template-columns:minmax(0,1fr) 17.5rem; gap:1.25rem; align-items:start }
    #iq-demo .iq-main { display:grid; gap:1rem; min-width:0 }
    #iq-demo .iq-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 }
    #iq-demo .iq-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; padding:1.125rem 1.25rem 0 }
    #iq-demo .iq-head h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
    #iq-demo .iq-head p { margin:.25rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #iq-demo .iq-form { display:grid; gap:1rem; padding:1.125rem 1.25rem 1.25rem; margin:0 }
    #iq-demo .iq-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem }
    #iq-demo .iq-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.875rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #iq-demo .iq-secure { display:inline-flex; align-items:center; gap:.375rem; color:var(--mv-fg-muted); font-size:.75rem }
    #iq-demo .iq-secure svg { width:.875rem; height:.875rem }
    #iq-demo .iq-foot-actions { display:flex; gap:.5rem }
    #iq-demo .iq-success { display:flex; align-items:center; gap:.75rem; padding:.875rem 1.25rem; border-top:1px solid var(--mv-border); background:color-mix(in oklab, var(--mv-success) 9%, var(--mv-surface)); font-size:.8125rem }
    #iq-demo .iq-success[hidden] { display:none }
    #iq-demo .iq-success svg { width:1.125rem; height:1.125rem; flex:none; color:var(--mv-success) }
    #iq-demo .iq-team .mv-empty { padding-block:1.5rem }
    #iq-demo .iq-hud { position:sticky; top:1rem; display:grid; gap:1rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
    #iq-demo .iq-hud h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; font-size:.8125rem; font-weight:600 }
    #iq-demo .iq-k { color:var(--mv-fg-muted); font-size:.6875rem; letter-spacing:.04em; text-transform:uppercase; font-weight:600 }
    #iq-demo .iq-levels { display:grid; grid-template-columns:repeat(4,1fr); gap:.25rem; margin:0; padding:0; list-style:none }
    #iq-demo .iq-levels li { display:grid; gap:.3125rem; justify-items:center; color:var(--mv-fg-subtle); font-size:.6875rem; text-align:center; line-height:1.2 }
    #iq-demo .iq-levels li::before { content:""; width:100%; height:.3125rem; border-radius:var(--mv-radius-full); background:var(--mv-bg-muted); transition:background-color var(--mv-duration-normal) }
    #iq-demo .iq-levels li[data-on]::before { background:var(--mv-accent) }
    #iq-demo .iq-levels li[data-now] { color:var(--mv-fg); font-weight:600 }
    #iq-demo .iq-meter { position:relative; height:.5rem; border-radius:var(--mv-radius-full); background:var(--mv-bg-muted); overflow:hidden }
    #iq-demo .iq-meter i { position:absolute; inset:0; border-radius:inherit; background:color-mix(in oklab, var(--mv-accent) 70%, transparent); transform-origin:left; scale:var(--v,0) 1; transition:scale 200ms linear }
    #iq-demo .iq-meter b { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:var(--mv-surface-raised) }
    #iq-demo .iq-meter-row { display:flex; justify-content:space-between; gap:.5rem; margin-top:.375rem; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
    #iq-demo .iq-stats { display:grid; grid-template-columns:1fr 1fr; gap:.5rem; margin:0 }
    #iq-demo .iq-stats div { display:grid; gap:.125rem; padding:.5rem .625rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
    #iq-demo .iq-stats dt { color:var(--mv-fg-muted); font-size:.6875rem }
    #iq-demo .iq-stats dd { margin:0; font-size:1rem; font-weight:650; letter-spacing:-.01em; font-variant-numeric:tabular-nums }
    #iq-demo .iq-stats dd small { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:500; letter-spacing:0 }
    #iq-demo .iq-log { display:grid; align-content:start; gap:.375rem; margin:0; padding:0; list-style:none; min-height:4.75rem }
    #iq-demo .iq-log li { display:flex; justify-content:space-between; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
    #iq-demo .iq-log li b { overflow:hidden; color:var(--mv-fg); font-weight:500; font-family:var(--mv-font-mono); font-size:.6875rem; text-overflow:ellipsis; white-space:nowrap }
    #iq-demo .iq-log li span { white-space:nowrap; font-variant-numeric:tabular-nums }
    #iq-demo .iq-sep { height:1px; background:var(--mv-border) }
    #iq-demo .iq-preview { display:flex; align-items:center; justify-content:space-between; gap:.5rem; flex-wrap:wrap }
    #iq-demo .iq-preview > span { display:flex; gap:.25rem }
    #iq-demo .iq-note { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center; text-wrap:balance }
    @media (max-width:54rem) {
      #iq-demo .iq-layout { grid-template-columns:minmax(0,1fr) }
      #iq-demo .iq-hud { position:static }
    }
    @media (max-width:34rem) {
      #iq-demo .iq-row { grid-template-columns:minmax(0,1fr) }
      #iq-demo .iq-secure { display:none }
      #iq-demo .iq-foot { justify-content:flex-end }
    }
  </style>

  <div class="iq-layout">
    <div class="iq-main">
      <!-- Main: a payouts form. target="auto" follows the first field that still blocks Save. -->
      <mv-insist id="iq-payout" goal="#iq-save" target="auto" delay="1s, 5s, 9s"
        hint="Everything checks out. Save to start receiving payouts."
        instruction="Choose Save payout method to finish this step.">
        <section class="iq-card" aria-labelledby="iq-title">
          <header class="iq-head">
            <div>
              <h3 id="iq-title">Set up payouts</h3>
              <p>Where should we send your earnings? Transfers go out every Friday.</p>
            </div>
            <span class="mv-badge" data-variant="secondary" data-shape="pill">Step 2 of 3</span>
          </header>
          <form class="iq-form" id="iq-form" onsubmit="event.preventDefault()">
            <div class="iq-row">
              <div class="mv-field">
                <label class="mv-label" for="iq-holder">Account holder</label>
                <input class="mv-input" id="iq-holder" value="Maya Chen" required autocomplete="name">
              </div>
              <div class="mv-field">
                <label class="mv-label" for="iq-country">Bank country</label>
                <select class="mv-select" id="iq-country">
                  <option selected>United States</option>
                  <option>Canada</option>
                  <option>Puerto Rico</option>
                </select>
              </div>
            </div>
            <div class="iq-row">
              <div class="mv-field">
                <label class="mv-label" for="iq-routing" data-required>Routing number</label>
                <input class="mv-input" id="iq-routing" inputmode="numeric" required pattern="[0-9]{9}" maxlength="9" placeholder="9 digits" autocomplete="off"
                  data-insist-hint="It’s the 9-digit number printed at the bottom left of your checks."
                  data-insist-instruction="Type the 9-digit routing number. To try it here, use 110000000.">
              </div>
              <div class="mv-field">
                <label class="mv-label" for="iq-account" data-required>Account number</label>
                <input class="mv-input" id="iq-account" inputmode="numeric" required pattern="[0-9]{4,17}" maxlength="17" placeholder="4 to 17 digits" autocomplete="off"
                  data-insist-hint="Your bank app shows it under account details, next to the routing number."
                  data-insist-instruction="Enter the account number from your bank app. To try it here, use 000123456789.">
              </div>
            </div>
          </form>
          <div class="iq-foot">
            <span class="iq-secure"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>Encrypted, never shown in full</span>
            <span class="iq-foot-actions">
              <button type="button" class="mv-button" data-variant="ghost" data-size="sm">Back</button>
              <button type="button" class="mv-button" data-size="sm" id="iq-save" aria-disabled="true">Save payout method</button>
            </span>
          </div>
          <div class="iq-success" id="iq-success" role="status" hidden>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m8.5 12.5 2.5 2.5 4.5-5"/></svg>
            <span id="iq-success-text">Payouts are set up. Your first transfer arrives Friday, September 25.</span>
          </div>
        </section>
      </mv-insist>

      <!-- Second instance: an empty state. The coordinator makes it wait while the form holds the floor. -->
      <mv-insist id="iq-team" class="iq-team" goal="#iq-invite" delay="3s"
        hint="Invite your accountant so they can download payout reports without your password."
        instruction="Choose Invite teammates and enter their work email.">
        <section class="mv-empty" data-variant="compact" data-border aria-labelledby="iq-team-title">
          <div class="mv-empty-media">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M19 8v6M16 11h6"/></svg>
          </div>
          <p class="mv-empty-title" id="iq-team-title">No teammates yet</p>
          <p class="mv-empty-description">Share payout reports with your team or accountant.</p>
          <div class="mv-empty-actions">
            <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="iq-invite">Invite teammates</button>
          </div>
        </section>
      </mv-insist>
    </div>

    <aside class="iq-hud" aria-label="Stuck detector">
      <h4>Stuck detector <span class="mv-badge" data-variant="secondary" data-shape="pill" id="iq-state">watching</span></h4>
      <ol class="iq-levels" id="iq-levels" aria-label="Hint level">
        <li data-on data-now>Quiet</li><li>Glow</li><li>Hint</li><li>Try this</li>
      </ol>
      <div>
        <div class="iq-meter" aria-hidden="true"><i id="iq-fill"></i><b id="iq-t1"></b><b id="iq-t2"></b></div>
        <div class="iq-meter-row"><span>Stuck clock</span><span id="iq-rate">×1</span></div>
      </div>
      <dl class="iq-stats">
        <div><dt>Stuck time</dt><dd id="iq-stuck">0.0 s</dd></div>
        <div><dt>No progress for</dt><dd id="iq-idle">0.0 s</dd></div>
        <div><dt>Wrong actions</dt><dd id="iq-mistakes">0</dd></div>
        <div><dt>Hesitation</dt><dd id="iq-hes">0.0 s</dd></div>
      </dl>
      <div class="iq-sep"></div>
      <div style="display:grid;gap:.5rem">
        <span class="iq-k">Events</span>
        <ol class="iq-log" id="iq-log"><li><b>watching…</b><span>0:00</span></li></ol>
      </div>
      <div class="iq-sep"></div>
      <div class="iq-preview">
        <span class="iq-k">Preview</span>
        <span>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-level="1" aria-label="Preview level 1, glow">1</button>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-level="2" aria-label="Preview level 2, coach mark">2</button>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-level="3" aria-label="Preview level 3, instruction">3</button>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="iq-restart">Restart</button>
        </span>
      </div>
      <label class="mv-choice" data-control="end">
        <input type="checkbox" role="switch" class="mv-switch" id="iq-remember">
        <span class="mv-choice-text"><span class="mv-choice-title">Remember “I’ve got it”</span></span>
      </label>
    </aside>
  </div>
  <p class="iq-note">Delays are shortened to 1, 5 and 9 seconds here. Press the disabled Save button, linger near a field or leave it alone: the hints escalate. Type a valid value and they stop.</p>

  <script type="module">
    const $ = (id) => document.getElementById(id);
    const pay = $("iq-payout"), team = $("iq-team"), save = $("iq-save");
    const fields = [$("iq-holder"), $("iq-routing"), $("iq-account")];
    await customElements.whenDefined("mv-insist");

    // The Save button stays aria-disabled (focusable, announced) until every field is valid.
    const sync = () => save.setAttribute("aria-disabled", String(!fields.every((f) => f.validity.valid)));
    $("iq-form").addEventListener("input", sync);
    sync();

    // Event log.
    let t0 = Date.now();
    const log = $("iq-log");
    const clock = () => { const s = Math.floor((Date.now() - t0) / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
    const add = (name, info) => {
      const li = document.createElement("li");
      const b = document.createElement("b");
      b.textContent = name;
      const s = document.createElement("span");
      s.textContent = info ? `${info} · ${clock()}` : clock();
      li.append(b, s);
      log.prepend(li);
      while (log.children.length > 4) log.lastElementChild.remove();
    };
    const who = (e) => (e.currentTarget === team ? "team · " : "");
    for (const el of [pay, team]) {
      el.addEventListener("mv-hint", (e) => add(`mv-hint ${e.detail.level}`, who(e) + e.detail.reason));
      el.addEventListener("mv-progress", (e) => add("mv-progress", who(e) + e.detail.reason));
      el.addEventListener("mv-hint-dismiss", (e) => add("mv-hint-dismiss", who(e) + e.detail.reason));
      el.addEventListener("mv-goal", (e) => add("mv-goal", `${who(e)}level ${e.detail.maxLevel}`));
    }
    pay.addEventListener("mv-goal", (e) => {
      const { maxLevel, mistakes } = e.detail;
      const help = maxLevel ? `after a level ${maxLevel} hint` : "with no help at all";
      $("iq-success-text").textContent = `Payouts are set up ${help}${mistakes ? ` and ${mistakes} wrong action${mistakes > 1 ? "s" : ""}` : ""}. Your first transfer arrives Friday, September 25.`;
      $("iq-success").hidden = false;
    });

    // Live monitor.
    const names = ["watching", "hinting", "waiting", "paused", "done", "dismissed"];
    const tone = { hinting: "info", waiting: "warning", done: "success", dismissed: "outline" };
    const sec = (ms) => `${(ms / 1000).toFixed(1)} s`;
    const paint = () => {
      const s = pay.signals;
      const [a, b, c] = s.thresholds;
      $("iq-t1").style.left = `${(a / c) * 100}%`;
      $("iq-t2").style.left = `${(b / c) * 100}%`;
      $("iq-fill").style.setProperty("--v", Math.min(1, s.stuck / c).toFixed(3));
      $("iq-stuck").textContent = sec(s.stuck);
      $("iq-idle").textContent = sec(s.idle);
      $("iq-hes").textContent = sec(s.hesitation);
      const top = Object.entries(s.reasons).sort((x, y) => y[1] - x[1])[0];
      $("iq-mistakes").replaceChildren(String(s.mistakes), ...(top ? [" ", Object.assign(document.createElement("small"), { textContent: top[0] })] : []));
      $("iq-rate").textContent = s.typing ? "paused · typing" : s.hesitating ? "×2.5 · hesitating" : "×1";
      [...$("iq-levels").children].forEach((li, i) => {
        li.toggleAttribute("data-on", i <= s.level);
        li.toggleAttribute("data-now", i === s.level);
      });
      const st = pay.state;
      const badge = $("iq-state");
      badge.textContent = st === "waiting" ? "waiting for its turn" : st;
      badge.dataset.variant = tone[st] ?? "secondary";
    };
    const timer = setInterval(() => (pay.isConnected ? paint() : clearInterval(timer)), 200);
    paint();

    // Controls.
    for (const btn of document.querySelectorAll("#iq-demo .iq-preview [data-level]")) {
      btn.addEventListener("click", () => {
        if (pay.state === "done" || pay.state === "dismissed") pay.reset();
        pay.show(Number(btn.dataset.level));
      });
    }
    $("iq-restart").addEventListener("click", () => {
      $("iq-routing").value = "";
      $("iq-account").value = "";
      sync();
      $("iq-success").hidden = true;
      pay.forget();
      team.forget();
      t0 = Date.now();
      log.replaceChildren();
      add("restart");
    });
    $("iq-remember").addEventListener("change", (e) => { pay.remember = e.target.checked ? "iq-demo-payouts" : null; });
  </script>
</div>

Cultural reference

The Legend of Zelda: Ocarina of Time — Nintendo EAD (Shigeru Miyamoto, Eiji Aonuma) (1998, game). The fairy companion stays quiet while the player makes progress, then glows, flies to what matters and calls out when the player seems stuck, its hints growing more direct the longer the struggle lasts. In the UI, a measured stuck signal (time, wrong actions, hesitation) escalates from a soft glow to a coach mark to a direct instruction with “Show me”, and any real progress silences it.

API

Attributes

NameTypeDefaultDescription
goalCSS selectorWhat the user should reach. A button or link is reached when clicked (not while disabled, and not a submit button whose form is still invalid), a field when it changes to a valid, non-empty value, a form when it is submitted. Looked up inside the element first, then in the document. Changing it resets the component: set the next step’s goal to chain an onboarding.
targetCSS selector | "auto"the goalWhat the hints point at, if not the goal itself. "auto" points at the first field in the region that is invalid (native constraints or aria-invalid), and falls back to the goal once everything is valid.
doneevent names (space or comma separated)Custom events that mean the goal is reached (e.g. "upload-complete"), when dispatched inside the region, on the goal, on the element, the document or window, bubbling or not.
delaytime | list of 2–3 times8sStuck time before each level. One value is spaced evenly (8s → 8s, 16s, 24s); two values extend the last gap (5s, 12s → 19s); three are used as given. Accepts s, ms, m or bare milliseconds.
max-level0 | 1 | 2 | 33Highest level the escalation may reach (1 = glow only). 0 keeps the detector running (signals, mv-progress, mv-goal) without ever showing a hint.
hintstringLevel 2 text when there is no <template data-level="2"> and the target has no data-insist-hint.
instructionstringLevel 3 text when there is no <template data-level="3"> and the target has no data-insist-instruction. Without any level 3 text, level 3 repeats the level 2 text with “Show me”.
rememberstring (storage key)Opt-in persistence: “I’ve got it” and reaching the goal are stored in localStorage under mv-insist:<key>, so the hints never come back on the next visit. forget() clears it.
placementbottom | top | left | right, with -start | -endbottomPreferred side of the coach mark relative to the target (flips and shifts to stay in the viewport).
prioritynumber0Coordinator rank. A higher-priority instance that becomes stuck takes the floor from the one showing a hint (which waits); equal priorities wait their turn.
disabledbooleanPauses everything: the hint hides, the clock stops, the reached level is kept for when it is re-enabled.
data-insist-hint / data-insist-instructionstring (on a field or control)Per-target texts for levels 2 and 3, used when that element is the target (made for target="auto"). They take precedence over the templates and attributes.
data-state / data-levelset by the componentdata-state: watching | hinting | waiting (another instance holds the floor) | paused | done | dismissed. data-level: the level on screen (absent at 0).

Properties

NameTypeDescription
level0 | 1 | 2 | 3Level on screen (read-only).
state"watching" | "hinting" | "waiting" | "paused" | "done" | "dismissed"Current state (read-only).
signals{ stuck, idle, hesitation, mistakes, reasons, level, reached, thresholds, typing, hesitating }What the detector sees (read-only): stuck = weighted stuck time in ms (the escalation clock), idle = plain time without progress, hesitation = ms spent lingering near the target, mistakes and reasons ({ disabled, invalid, rage, "back-and-forth", api: count }), reached = escalation level (may exceed level while waiting or vetoed), thresholds = the three delays in ms, typing / hesitating = whether the clock is currently frozen / sped up.
stringsPartial<Record<string, string>>Overrides for visible texts and announcements (keys: kicker2, kicker3, showMe, dismiss, above, below, announce2 ({text}), announce3 ({text})). English defaults.
MvInsist.activeMvInsist | nullStatic: the instance currently holding the floor (showing a hint).

Methods

NameDescription
show(level)Forces a level now, as if the user had been stuck that long (clamped to max-level, goes through mv-hint and the coordinator). Returns true if it is on screen.
progress(reason?)Tells the component the user moved forward (a step the DOM cannot show, e.g. a file finished uploading): hides the hint and restarts the clock.
mistake(reason?)Reports a wrong action the component cannot see (server rejected the value, failed upload…): worth half a delay, never skips a level.
complete(source?)Marks the goal as reached: hints stop, mv-goal fires, stored with remember.
dismiss({ remember = true })Stops hints for good, like the “I’ve got it” button.
reset()Starts over (clock, signals, done or dismissed state in memory). A stored dismissal is kept.
forget()Clears the stored dismissal or completion, then reset().

Events

NameDescription
mv-hintCancelable, before a level appears. detail: { level, previous, target, text, reason: "idle" | "friction" | "hesitation" | "api", signals }. preventDefault() vetoes that level (not retried until the escalation reaches a higher one): show your own UI instead, or log it.
mv-hint-dismissThe user closed a hint. detail: { level, reason: "button" | "escape" | "api", remembered, permanent }. Escape is a snooze (permanent false): the hint steps back one level and the snoozed level returns after one more delay of being stuck.
mv-goalThe goal is reached. detail: { source: "click" | "change" | "submit" | "event" | "api", level (on screen at that moment), maxLevel (highest level shown), elapsed (ms since start), mistakes }.
mv-progressProgress reset the escalation. detail: { reason: "field" | "fixed" | "api" | …, level (the level that was on screen) }.
mv-show-meCancelable, when “Show me” is chosen. detail: { target, level }. Open the accordion or tab that contains the target here; preventDefault() replaces the built-in scroll and focus.

Content structure

NameDescription
(content)The region to watch: a form, an onboarding card, an empty state. The component appends its own layer (halo), coach mark (popover) and live region at the end.
template[data-level="1|2|3"]Level content as direct child templates. Levels 2 and 3 may contain rich inline markup (strong, kbd, a); data-target on a template points that level at another element; data-show-me="false" on level 3 hides “Show me”.

CSS classes

NameDescription
mv-insist-layer / mv-insist-haloaria-hidden overlay with the glow ring positioned around the target. data-level="1|2|3|done", data-fly while it travels to a new target, data-off when the target is out of view.
mv-insist-markCoach mark (Popover API, top layer, non-modal): -head (-kicker + -kicker-icon, -where: off-screen direction), -body, -actions, -show (“Show me”), -dismiss (“I’ve got it”). data-level, data-side, data-off="above | below" when docked at the viewport edge.

CSS variables

NameDefaultDescription
--mv-insist-colorvar(--mv-accent)Halo, ripple and level 3 accents.
--mv-insist-successvar(--mv-success)Color of the brief confirmation ring when the goal is reached while a hint is up.

Accessibility

Nothing is modal and focus is never moved by the component on its own: hints appear, change and disappear around what the user is doing. Level 1 is visual only (the halo is aria-hidden, pointer-events: none). Levels 2 and 3 open a labelled group in the top layer (Popover API, manual, so it never grabs focus) and are announced once in a polite live region (“Hint: …”, “Try this: …”); the coach mark text is added to the target’s aria-describedby while it is shown, so it is read again whenever the target gets focus, and removed afterwards. The coach mark follows the region in the Tab order; Escape (from the region or the target, unless another component handled it) snoozes the hint, “I’ve got it” stops hints for good, and if focus was inside the coach mark when it closes it moves to the target, never to the page. “Show me” is the only action that moves focus, and only because the user asked: it scrolls the target into view (smoothly unless reduced motion) and focuses it, but never activates it. Escalation is deferred while the user is typing or pressing, so a hint never appears under someone mid-keystroke, and progress removes it immediately. Keyboard activation of a disabled control counts as a wrong action like a click. When the target is scrolled out of view, the coach mark docks at the viewport edge with a direction label and offers “Show me”. Reduced motion (OS or data-motion="reduce"): no ripple, no flight, no confirmation flash; the ring is static. Forced colors: the halo becomes a Highlight outline and the coach mark a CanvasText border. Colors always come with shape and words (ring, labels).