独家

Figurines <mv-figurines>

剩余尝试次数指示器,在锁定之前让一个小而硬性的限制变得具体可感:验证码、PIN 或密码尝试、支付重试、答题次数、免费导出。

文化出处

无人生还,阿加莎·克里斯蒂(1939 年,书籍)。餐桌上立着十个小瓷人,每死一个人就少一个,于是每个人都能准确数出还剩几个,并害怕最后一个的到来。在 UI 中,每次有限的尝试都是立在一条线上的小标记;每次失败都会推倒一个,留下它的空位,文字说明归零后会发生什么,最后一个会在锁定前被特别标出。

工作原理

剩余尝试次数指示器,在锁定之前让一个小而严格的限制变得可感知:验证码、PIN 或密码尝试、支付重试、答题次数、免费导出。每次尝试对应一个中性的小标记,立在一条细线上;每次失败都会让一个标记以短暂、平静的方式倒下,并在原处留下虚线轮廓,剩下的和用掉的都能一眼数清。文字始终给出次数以及归零意味着什么(“3 of 5 attempts left · then you’ll have to wait 15 minutes”);最后一个标记带圆环、更大且为暖色,整个指示器会切换为更醒目但不惊扰的“Last attempt”警告。归零时,它可以禁用关联的字段(焦点保持在说明文字上),并显示你的 exhausted 文本或富内容插槽(例如一个“Update card”链接);设置 cooldown 或绝对时间 until 后,它会倒计时,同时细线重新填满,然后把所有标记重新立起来。fail() 会经过可取消的 mv-attempt-fail;reset()、restore(n) 和 sync({ remaining, max, until }) 让它与服务器保持一致,且始终以服务器的计数为准。for 通过 aria-describedby 把它关联到字段(包括自定义元素内部的 input),每次变化都会以礼貌模式播报,最后一次尝试和锁定则以强调模式播报。

分类反馈
类型Web Component(<mv-figurines>)
状态稳定版
套件诚实的系统状态
Keywordsexclusive, culture, attempts, retries, lockout, rate-limit, otp, 2fa, pin, password, login, security, payment, dunning, quiz, assessment, quota, free-tier, cooldown, countdown, form, hint

When to use

  • A 2FA code, PIN or password field allows a few tries before the account or device locks for a while
  • A failed payment can be retried a limited number of times before the subscription is suspended
  • An assessment or quiz gives each question a few answer attempts before revealing the answer
  • A free tier grants a handful of exports or generations and users should see the last one coming

Avoid when

  • The budget refills continuously over time or is large and metered (API calls, AI messages, credits) → use Recharge instead
  • A session is about to expire and someone must confirm they are still there → use Hatch instead
  • The limit is only enforced client-side: it informs, the server must still count attempts and lock

安装

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

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["figurines"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(包括依赖):tokens/tokens.css、core/base.css、core/dom.js、core/element.js、core/motion.js、components/figurines/figurines.js、components/figurines/figurines.css。

用法

快速开始,最小可运行标记:

<input id="pin" inputmode="numeric" aria-label="PIN">
<mv-figurines for="pin" max="5" remaining="3" cooldown="15m"></mv-figurines>
<!-- on each wrong PIN: figurines.fail() -->

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="fg-demo" style="width:min(100%,56rem);margin-inline:auto">
  <style>
    #fg-demo { display:grid; gap:1rem; align-content:start }
    #fg-demo .fg-main { display:grid; grid-template-columns:minmax(0,1fr) 16.5rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #fg-demo .fg-signin { display:grid; grid-template-columns:minmax(0,1fr); gap:1rem; align-content:start; padding:1.5rem 1.75rem 1.625rem }
    #fg-demo .fg-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.875rem; letter-spacing:-.01em; color:var(--mv-fg) }
    #fg-demo .fg-logo { display:grid; place-items:center; width:1.625rem; height:1.625rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #fg-demo .fg-logo svg { width:.9375rem; height:.9375rem }
    #fg-demo h3 { margin:0; font-size:1.25rem; letter-spacing:-.015em }
    #fg-demo .fg-lead { margin:.25rem 0 0; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.5 }
    #fg-demo .fg-field { display:grid; gap:.5rem; justify-items:start }
    #fg-demo .fg-field label { font-size:.8125rem; font-weight:600 }
    #fg-demo .fg-row { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap }
    #fg-demo .fg-msg { min-height:1.25rem; margin:0; font-size:.8125rem; color:var(--mv-fg-muted) }
    #fg-demo .fg-msg[data-tone="error"] { color:var(--mv-danger) }
    #fg-demo .fg-msg[data-tone="ok"] { color:var(--mv-success) }
    #fg-demo .fg-hint { color:var(--mv-fg-subtle); font-size:.75rem }
    #fg-demo .fg-hint code { font:600 .75rem var(--mv-font-mono); color:var(--mv-fg-muted) }
    #fg-demo .fg-side { display:grid; gap:.875rem; align-content:start; padding:1.25rem; border-inline-start:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #fg-demo .fg-k { margin:0; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
    #fg-demo .fg-actions { display:grid; gap:.5rem }
    #fg-demo .fg-actions .mv-button { justify-content:flex-start }
    #fg-demo .fg-log { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.6875rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
    #fg-demo .fg-log li:first-child { color:var(--mv-fg) }
    #fg-demo .fg-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem }
    #fg-demo .fg-card { display:grid; gap:.75rem; align-content:start; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
    #fg-demo .fg-card h4 { margin:0; font-size:.875rem; letter-spacing:-.005em }
    #fg-demo .fg-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
    #fg-demo .fg-file { display:flex; align-items:center; gap:.625rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); font-size:.8125rem }
    #fg-demo .fg-file svg { width:1.125rem; height:1.125rem; color:var(--mv-fg-subtle); flex:none }
    #fg-demo .fg-file span { display:grid; min-width:0 }
    #fg-demo .fg-file small { color:var(--mv-fg-subtle); font-size:.6875rem }
    #fg-demo .fg-choices { display:grid; gap:.375rem }
    #fg-demo .fg-choices .mv-button { justify-content:flex-start; width:100% }
    #fg-demo .fg-choices .mv-button[data-picked="wrong"] { text-decoration:line-through; color:var(--mv-fg-subtle) }
    #fg-demo .fg-choices .mv-button[data-picked="right"] { border-color:var(--mv-success); color:var(--mv-success) }
    #fg-demo .fg-inv { display:flex; justify-content:space-between; gap:.5rem; font-size:.8125rem }
    #fg-demo .fg-inv b { font-variant-numeric:tabular-nums }
    @media (max-width:46rem) {
      #fg-demo .fg-main { grid-template-columns:minmax(0,1fr) }
      #fg-demo .fg-side { border-inline-start:0; border-top:1px solid var(--mv-border) }
      #fg-demo .fg-grid { grid-template-columns:minmax(0,1fr) }
      #fg-demo .fg-signin { padding:1.25rem }
      #fg-demo mv-otp { --mv-otp-slot-width:2.25rem; --mv-otp-slot-height:2.75rem; --mv-otp-gap:.375rem }
    }
  </style>

  <!-- Main: two-step sign-in, 5 code attempts (2 already spent), a 30 s pause at zero (15 min in production) -->
  <section class="fg-main" aria-label="Two-step verification">
    <form class="fg-signin" id="fg-form" novalidate>
      <span class="fg-brand">
        <span class="fg-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4 7v5c0 4.5 3.4 8.2 8 9 4.6-.8 8-4.5 8-9V7z"/></svg></span>
        Acme
      </span>
      <div>
        <h3>Check your phone</h3>
        <p class="fg-lead">Enter the 6-digit code we texted to (•••) •••-••42 to finish signing in as [email protected].</p>
      </div>
      <div class="fg-field">
        <label for="fg-otp">Verification code</label>
        <mv-otp id="fg-otp" length="6" separator></mv-otp>
      </div>
      <mv-figurines id="fg-left" for="fg-otp fg-verify" max="5" remaining="3" cooldown="30s" disable-at-zero
        exhausted="Sign-in is paused to protect this account"></mv-figurines>
      <div class="fg-row">
        <button type="submit" class="mv-button" id="fg-verify">Verify and sign in</button>
        <span class="fg-hint">The right code is <code>424242</code></span>
      </div>
      <p class="fg-msg" id="fg-msg" aria-live="polite"></p>
    </form>
    <aside class="fg-side" aria-label="Demo controls">
      <p class="fg-k">Simulate</p>
      <div class="fg-actions">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fg-wrong">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>
          Submit a wrong code
        </button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fg-sync">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12a8 8 0 0 1 14-5.3L20 9M20 4v5h-5M20 12a8 8 0 0 1-14 5.3L4 15M4 20v-5h5"/></svg>
          Server: 1 attempt left
        </button>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="fg-reset">Start over</button>
      </div>
      <p class="fg-k">Events</p>
      <ol class="fg-log" id="fg-log" aria-label="Event log"><li>Ready · 3 of 5 left</li></ol>
    </aside>
  </section>

  <div class="fg-grid">
    <!-- Free-tier exports: the last one is marked -->
    <section class="fg-card" aria-labelledby="fg-ex-title">
      <h4 id="fg-ex-title">Free exports</h4>
      <div class="fg-file">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>
        <span>Q3 board deck.pdf<small>24 slides · 8.2 MB</small></span>
      </div>
      <mv-figurines id="fg-exports" data-variant="plain" shape="dot" unit="free export|free exports" max="3" remaining="1" for="fg-export" disable-at-zero
        consequence="then exports need the Pro plan" exhausted="Upgrade to keep exporting"></mv-figurines>
      <div class="fg-row">
        <button type="button" class="mv-button" data-size="sm" id="fg-export">Export PDF</button>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="fg-upgrade">Upgrade</button>
      </div>
    </section>

    <!-- Payment retries: exhausted, counting down to an absolute time -->
    <section class="fg-card" aria-labelledby="fg-pay-title">
      <h4 id="fg-pay-title">Invoice INV-3187</h4>
      <div class="fg-inv"><span>Team plan · October</span><b>$240.00</b></div>
      <mv-figurines id="fg-pay" shape="square" unit="retry|retries" max="3" remaining="0" for="fg-retry" disable-at-zero data-size="sm">
        <span slot="exhausted">Card ending 4417 was declined. <a href="#" id="fg-card">Update card</a></span>
      </mv-figurines>
      <div class="fg-row">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fg-retry">Retry payment</button>
      </div>
    </section>

    <!-- Assessment: three tries, stacked -->
    <section class="fg-card" aria-labelledby="fg-q-title">
      <h4 id="fg-q-title">Knowledge check · Q4</h4>
      <p>Which HTTP status tells a client it is sending too many requests?</p>
      <div class="fg-choices" id="fg-choices">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-answer="403">403 Forbidden</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-answer="503" data-picked="wrong" disabled>503 Service Unavailable</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-answer="429">429 Too Many Requests</button>
      </div>
      <mv-figurines id="fg-quiz" data-variant="stacked" shape="diamond" max="3" remaining="2" unit="try|tries"
        consequence="then the answer is shown" exhausted="The answer was 429 Too Many Requests"></mv-figurines>
    </section>
  </div>

  <script type="module">
    const $ = (id) => document.getElementById(id);
    const left = $("fg-left");
    const otp = $("fg-otp");
    const msg = $("fg-msg");
    const log = $("fg-log");
    const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    const note = (text) => {
      const li = document.createElement("li");
      li.textContent = `${time()} · ${text}`;
      log.prepend(li);
      while (log.children.length > 6) log.lastElementChild.remove();
    };
    const say = (text, tone = "") => { msg.textContent = text; msg.dataset.tone = tone; };

    // Sign-in: the app checks the code; figurines only counts and explains.
    $("fg-form").addEventListener("submit", (e) => {
      e.preventDefault();
      if (left.remaining === 0) return;
      const code = otp.value ?? "";
      if (code.length < 6) { say("Enter all 6 digits.", "error"); otp.focus?.(); return; }
      if (code === "424242") {
        say("Code confirmed. Signing you in…", "ok");
        otp.valid = true;
        left.reset();
        return;
      }
      otp.invalid = true;
      say("That code isn’t right.", "error");
      left.fail();
      otp.clear?.();
    });
    $("fg-wrong").addEventListener("click", () => { say("That code isn’t right.", "error"); left.fail(); });
    $("fg-sync").addEventListener("click", () => left.sync({ remaining: 1 }));
    $("fg-reset").addEventListener("click", () => { left.reset(); say(""); otp.clear?.(); });

    left.addEventListener("mv-attempt-fail", (e) => note(`mv-attempt-fail · ${e.detail.remaining} left${e.detail.last ? " (last)" : ""}`));
    left.addEventListener("mv-exhausted", (e) => {
      note(`mv-exhausted · until ${new Date(e.detail.until).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" })}`);
      say("");
    });
    left.addEventListener("mv-replenish", (e) => { note(`mv-replenish · ${e.detail.reason} · ${e.detail.remaining} left`); if (e.detail.reason === "cooldown") say(""); });

    // Exports: spending a free export is not a failure, but it is the same countdown.
    const exportsLeft = $("fg-exports");
    $("fg-export").addEventListener("click", () => exportsLeft.fail());
    $("fg-upgrade").addEventListener("click", () => exportsLeft.reset());

    // Payment: the server says retries are paused until an absolute time.
    const pay = $("fg-pay");
    pay.sync({ until: Date.now() + 45_000 });
    $("fg-retry").addEventListener("click", () => pay.fail());
    $("fg-card").addEventListener("click", (e) => { e.preventDefault(); pay.reset(); });

    // Quiz: each wrong pick spends a try; the right one ends the question.
    const quiz = $("fg-quiz");
    const choices = $("fg-choices");
    const lock = () => choices.querySelectorAll("button").forEach((b) => { b.disabled = true; if (b.dataset.answer === "429") b.dataset.picked = "right"; });
    choices.addEventListener("click", (e) => {
      const b = e.target.closest("button[data-answer]");
      if (!b || b.disabled) return;
      if (b.dataset.answer === "429") { b.dataset.picked = "right"; lock(); return; }
      b.dataset.picked = "wrong";
      b.disabled = true;
      if (quiz.fail() === 0) lock();
    });
  </script>
</div>

API

Attributes

Name类型DefaultDescription
maxnumber (1-24)5Number of attempts, one token each. Changing it rebuilds the row without animation.
remainingnumbermaxAttempts left. Reflected by the component; setting it from outside (server count) animates the difference and announces it, like sync().
cooldowntime ("15m", "30s", "1h", "1d", ms number)Wait applied when the count reaches zero without a future until: the tokens come back cooldown later. Also writes the default consequence (“then you’ll have to wait 15 minutes”).
untilISO date | epoch msAbsolute time at which every token comes back (your server’s lockout end or quota reset). Under an hour the text counts down (“Try again in 14:32”); beyond, it states the time (“Try again tomorrow at 9:00 AM”). Derived from Date.now(), so throttled background tabs don’t drift. Reflected when the component computes it from cooldown.
consequencestringWhat happens at zero, written to follow the count (“then this account locks for 15 minutes”). Unslotted child content takes precedence.
exhaustedstringText shown at zero instead of the consequence (“Sign-in is paused to protect this account”). slot="exhausted" content takes precedence.
unit"singular|plural"attempt|attemptsNoun used in every text and announcement: “try|tries”, “retry|retries”, “free export|free exports”…
forid listSpace-separated ids of the field(s) and button(s) the count applies to. Each gets aria-describedby pointing to the status (and so does the inner native control of a custom element such as mv-otp).
disable-at-zerobooleanSets disabled on the for targets while the count is zero and afterwards re-enables exactly the ones it disabled. If one had focus, focus moves to the status text instead of being lost.
revealalways | after-failalwaysafter-fail hides the indicator while no attempt has been spent (it stays referenced by aria-describedby), then fades it in.
shapepeg | dot | square | diamondpegAbstract token shape.
quietbooleanNo live announcements (when your form already announces its own errors together with the count).
localeBCP 47en-USLocale of the absolute time in “Try again at 3:45 PM”.
data-variantplain | stackedDefault: a subtle box. plain: no box, for a hint line under a field. stacked: tokens above centered text, for PIN pads, lock screens and quiz cards.
data-sizesm | lgToken and text size.
data-statefull | spent | last | exhaustedSet by the component (styleable). data-cooldown is added while a countdown runs, data-concealed while reveal="after-fail" hides it.

Properties

Name类型Description
remainingnumberAttempts left. Setting it is a server sync: the difference animates and is announced, no mv-attempt-fail.
maxnumberNumber of tokens (mirrors the attribute).
untilnumber (epoch ms) | Date | string | nullWhen the tokens come back. Read it, or set it from the server’s lockout end.
state"full" | "spent" | "last" | "exhausted"Current phase (read-only).
stringsPartial<Record<string, string>>Overrides for every text and announcement (keys: unit, count ({n} {max} {units}), last ({unit}), none ({units}), consequenceWait ({duration}), cooldown ({time}), cooldownAt ({at}), timer ({time} {at}), announceLast ({consequence}), announceNone ({exhausted}), announceReplenish ({Units} {count}), announceReady ({count})). English defaults.

Methods

NameDescription
fail()One attempt spent: emits the cancelable mv-attempt-fail, tips the last standing token over, announces the count; at zero starts the cooldown and emits mv-exhausted. Returns the attempts left (unchanged if cancelled).
reset()Stands every token back up and clears until (after a success, a password reset, a new card). Cancelable mv-replenish; returns false if refused.
restore(n = 1)Gives back n attempts (earned retry, support grant). Cancelable mv-replenish.
sync({ remaining?, max?, until? })Aligns with the server’s truth in one call. Losses tip tokens over without mv-attempt-fail; reaching zero emits mv-exhausted (reason "sync"); gains emit a non-cancelable mv-replenish.

Events

NameDescription
mv-attempt-failCancelable, before a token falls. detail: { remaining (after), previous, max, last (true when one is left) }. preventDefault() keeps the count (e.g. a network error that should not cost an attempt).
mv-exhaustedThe count reached zero. detail: { reason: "fail" | "sync", until (epoch ms or null), max }.
mv-replenishTokens come back. detail: { reason: "reset" | "restore" | "cooldown" | "sync", previous, remaining, max }. Cancelable except for sync; cancelling a cooldown refill leaves the count at zero so you can ask the server first.

Content structure

NameDescription
(default)Rich consequence shown after the count before zero (may contain a link).
exhaustedRich content shown at zero instead of the consequence (“Card ending 4417 was declined. Update card”).

CSS classes

NameDescription
mv-figurines-rowDecorative token row (aria-hidden, data-shape): .mv-figurines-tokens > .mv-figurines-slot-token (data-spent, data-last) > .mv-figurines-token, and .mv-figurines-line > .mv-figurines-line-fill (cooldown progress).
mv-figurines-statusThe text referenced by aria-describedby (focusable with tabindex=-1): -icon, -text, -count, -sep, -consequence, -exhausted, -clock.

CSS variables

NameDefaultDescription
--mv-figurines-tokenvar(--mv-accent)Standing token color, and the cooldown refill line.
--mv-figurines-lastvar(--mv-warning)Color of the last token and of the last-attempt state.
--mv-figurines-exhaustedvar(--mv-danger)Tint of the zero state.
--mv-figurines-ghostvar(--mv-border-strong)Dashed outline left by a spent token.
--mv-figurines-gap0.3125remSpace between tokens.
--mv-figurines-progress0Cooldown progress 0-1, set by the component (read it to style your own bar).

Accessibility

The text is always there and never relies on color: “3 of 5 attempts left · then you’ll have to wait 15 minutes”, “Last attempt” with a warning icon, “No attempts left” with a lock icon; the last token is also larger and ringed. The token row is aria-hidden and ignores the pointer. for adds the status id to aria-describedby on each target (and on the native input inside a custom element such as mv-otp, once it is upgraded), so the count and consequence are read with the field; the ids are removed on disconnect. Each failure is announced in a polite live region (“2 of 5 attempts left.”); the last attempt is announced assertively with its consequence (“Last attempt. Then you’ll have to wait 15 minutes.”), and so is the lockout (“No attempts left. Sign-in is paused to protect this account. Try again at 3:45 PM.”); refills are announced politely. quiet turns announcements off when your form already speaks. The visible m:ss countdown is aria-hidden; a role="timer" element inside the status (implicitly aria-live off) gives the spoken form on demand (“Try again in 28 seconds, at 3:45 PM”), so nothing chatters every second. disable-at-zero only re-enables what it disabled, and moves focus to the status text (tabindex=-1, visible focus ring) when the focused field gets disabled, instead of dropping it to the page. Reduced motion (OS or data-motion="reduce"): tokens fade instead of toppling or rising, the neighbor does not wobble, the halo does not pulse, the refill line jumps. Forced colors: tokens use CanvasText, the last one Highlight, spent outlines GrayText. RTL: tokens fall toward the inline end.

本页面由 AI 翻译。报告翻译问题