Exclusivebeta
Figurines — <mv-figurines>
Remaining-attempts indicator that makes a small, hard limit tangible before a lockout: code, PIN or password tries, payment retries, answer attempts, free exports. One neutral token per attempt stands on a thin line; each failure tips one over with a brief, calm topple and leaves a dashed outline in its place, so what is left and what was spent are both countable at a glance. The text always gives the count and what zero means (“3 of 5 attempts left · then you’ll have to wait 15 minutes”); the last token is ringed, larger and warm, and the whole indicator shifts to a stronger but non-alarming “Last attempt” warning. At zero it can disable the linked fields (keeping focus on the explanation) and shows your exhausted text or rich slot (an “Update card” link); with cooldown or an absolute until it counts down while the line fills back, then stands every token back up. fail() goes through a cancelable mv-attempt-fail; reset(), restore(n) and sync({ remaining, max, until }) keep it aligned with the server, whose count always wins. for links it to the field through aria-describedby (including the inner input of custom elements), and each change is announced politely, the last attempt and the lockout assertively.
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-figurines>) |
| Status | beta |
| 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
Install
node scripts/add.mjs figurines --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["figurines"], 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/motion.js, components/figurines/figurines.js, components/figurines/figurines.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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>Cultural reference
And Then There Were None — Agatha Christie (1939, book). Ten figurines stand on the dining table and one disappears after each death, so everyone can count exactly how many remain and dread the last one. In the UI each limited attempt is a small token standing on a line; each failure tips one over and leaves its empty place, the text says what happens at zero, and the last one is marked before the lockout.
API
Attributes
| Name | Type | 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 | Type | 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.