Exclusivobeta
Figurines <mv-figurines>
Indicador de tentativas restantes que torna palpável um limite pequeno e rígido antes de um bloqueio: tentativas de código, PIN ou senha, novas tentativas de pagamento, tentativas de resposta, exportações gratuitas. Uma ficha neutra por tentativa fica em pé sobre uma linha fina; cada falha derruba uma com uma queda breve e tranquila e deixa um contorno tracejado no lugar, então o que resta e o que já foi gasto podem ser contados de relance. O texto sempre dá o número e o que significa chegar a zero (“3 of 5 attempts left · then you’ll have to wait 15 minutes”); a última ficha tem anel, é maior e de tom quente, e o indicador inteiro passa para um aviso “Last attempt” mais forte, mas sem alarmismo. Ao chegar a zero, ele pode desativar os campos vinculados (mantendo o foco na explicação) e mostra o seu texto de esgotamento ou um slot rico (um link “Update card”); com cooldown ou um until absoluto, faz uma contagem regressiva enquanto a linha volta a se preencher e depois põe todas as fichas de pé de novo. fail() passa por um mv-attempt-fail cancelável; reset(), restore(n) e sync({ remaining, max, until }) o mantêm alinhado com o servidor, cuja contagem sempre prevalece. for o vincula ao campo via aria-describedby (inclusive o input interno de custom elements), e cada mudança é anunciada de forma polite, a última tentativa e o bloqueio de forma assertiva.
| Categoria | Feedback |
|---|---|
| Tipo | Web Component (<mv-figurines>) |
| Status | beta |
| Kit | Estados do sistema honestos |
| Keywords | exclusive, 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
Instalação
node scripts/add.mjs figurines --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["figurines"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/figurines/figurines.js, components/figurines/figurines.css.
Uso
Início rápido, a menor marcação 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() -->Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis 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>Referência cultural
E Não Sobrou Nenhum, Agatha Christie (1939, livro). Dez estatuetas ficam sobre a mesa de jantar e uma desaparece a cada morte, então todos podem contar exatamente quantas restam e temer a última. Na interface, cada tentativa limitada é uma pequena ficha de pé sobre uma linha; cada falha derruba uma e deixa seu lugar vazio, o texto diz o que acontece ao chegar a zero, e a última é marcada antes do bloqueio.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
max | number (1-24) | 5 | Number of attempts, one token each. Changing it rebuilds the row without animation. |
remaining | number | max | Attempts left. Reflected by the component; setting it from outside (server count) animates the difference and announces it, like sync(). |
cooldown | time ("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”). | |
until | ISO date | epoch ms | Absolute 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. | |
consequence | string | What happens at zero, written to follow the count (“then this account locks for 15 minutes”). Unslotted child content takes precedence. | |
exhausted | string | Text shown at zero instead of the consequence (“Sign-in is paused to protect this account”). slot="exhausted" content takes precedence. | |
unit | "singular|plural" | attempt|attempts | Noun used in every text and announcement: “try|tries”, “retry|retries”, “free export|free exports”… |
for | id list | Space-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-zero | boolean | Sets 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. | |
reveal | always | after-fail | always | after-fail hides the indicator while no attempt has been spent (it stays referenced by aria-describedby), then fades it in. |
shape | peg | dot | square | diamond | peg | Abstract token shape. |
quiet | boolean | No live announcements (when your form already announces its own errors together with the count). | |
locale | BCP 47 | en-US | Locale of the absolute time in “Try again at 3:45 PM”. |
data-variant | plain | stacked | Default: 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-size | sm | lg | Token and text size. | |
data-state | full | spent | last | exhausted | Set by the component (styleable). data-cooldown is added while a countdown runs, data-concealed while reveal="after-fail" hides it. |
Properties
| Name | Tipo | Description |
|---|---|---|
remaining | number | Attempts left. Setting it is a server sync: the difference animates and is announced, no mv-attempt-fail. |
max | number | Number of tokens (mirrors the attribute). |
until | number (epoch ms) | Date | string | null | When the tokens come back. Read it, or set it from the server’s lockout end. |
state | "full" | "spent" | "last" | "exhausted" | Current phase (read-only). |
strings | Partial<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
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-attempt-fail | Cancelable, 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-exhausted | The count reached zero. detail: { reason: "fail" | "sync", until (epoch ms or null), max }. |
mv-replenish | Tokens 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
| Name | Description |
|---|---|
(default) | Rich consequence shown after the count before zero (may contain a link). |
exhausted | Rich content shown at zero instead of the consequence (“Card ending 4417 was declined. Update card”). |
CSS classes
| Name | Description |
|---|---|
mv-figurines-row | Decorative 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-status | The text referenced by aria-describedby (focusable with tabindex=-1): -icon, -text, -count, -sep, -consequence, -exhausted, -clock. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-figurines-token | var(--mv-accent) | Standing token color, and the cooldown refill line. |
--mv-figurines-last | var(--mv-warning) | Color of the last token and of the last-attempt state. |
--mv-figurines-exhausted | var(--mv-danger) | Tint of the zero state. |
--mv-figurines-ghost | var(--mv-border-strong) | Dashed outline left by a spent token. |
--mv-figurines-gap | 0.3125rem | Space between tokens. |
--mv-figurines-progress | 0 | Cooldown 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.