Exclusivobeta

Figurines <mv-figurines>

Indicador de intentos restantes que hace tangible un límite pequeño y estricto antes de un bloqueo: intentos de código, PIN o contraseña, reintentos de pago, intentos de respuesta, exportaciones gratuitas. Una ficha neutra por intento se alza sobre una línea fina; cada fallo derriba una con una caída breve y serena y deja un contorno discontinuo en su lugar, así que lo que queda y lo que se ha gastado se cuentan de un vistazo. El texto siempre da la cifra y lo que significa llegar a cero (“Quedan 3 de 5 intentos · después tendrás que esperar 15 minutos”); la última ficha lleva anillo, es más grande y de tono cálido, y todo el indicador pasa a un aviso “Último intento” más fuerte pero sin alarmismo. Al llegar a cero puede desactivar los campos vinculados (manteniendo el foco en la explicación) y muestra tu texto de agotamiento o un slot enriquecido (un enlace “Actualizar tarjeta”); con cooldown o un until absoluto, hace una cuenta regresiva mientras la línea se vuelve a llenar y luego levanta de nuevo todas las fichas. fail() pasa por un mv-attempt-fail cancelable; reset(), restore(n) y sync({ remaining, max, until }) lo mantienen alineado con el servidor, cuyo recuento siempre prevalece. for lo vincula al campo mediante aria-describedby (incluido el input interno de los custom elements), y cada cambio se anuncia de forma cortés, y el último intento y el bloqueo, de forma asertiva.

CategoríaFeedback
TipoWeb Component (<mv-figurines>)
Estadobeta
KitEstados del sistema honestos
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

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["figurines"], 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/motion.js, components/figurines/figurines.js, components/figurines/figurines.css.

Uso

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

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

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables 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>
        Northbeam
      </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>

Referencia cultural

Y no quedó ninguno, Agatha Christie (1939, libro). Diez figuritas están sobre la mesa del comedor y desaparece una tras cada muerte, así que todos pueden contar exactamente cuántas quedan y temer la última. En la interfaz, cada intento limitado es una pequeña ficha sobre una línea; cada fallo derriba una y deja su lugar vacío, el texto dice qué pasa al llegar a cero, y la última se marca antes del bloqueo.

API

Attributes

NameTipoDefaultDescription
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

NameTipoDescription
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.

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