Exclusifbêta
Figurines <mv-figurines>
Indicateur de tentatives restantes qui rend tangible une limite courte et stricte avant un verrouillage : essais de code, de PIN ou de mot de passe, nouvelles tentatives de paiement, essais de réponse, exports gratuits. Un jeton neutre par tentative se tient debout sur une fine ligne ; chaque échec en renverse un par une chute brève et calme et laisse un contour pointillé à sa place : ce qui reste et ce qui a été consommé se comptent d’un coup d’œil. Le texte donne toujours le nombre et ce que signifie zéro (« 3 tentatives restantes sur 5 · ensuite, vous devrez attendre 15 minutes ») ; le dernier jeton est cerclé, plus grand et de teinte chaude, et tout l’indicateur passe à un avertissement « Dernière tentative » plus marqué mais pas alarmant. À zéro, il peut désactiver les champs liés (en gardant le focus sur l’explication) et affiche votre texte d’épuisement ou un slot riche (un lien « Mettre à jour la carte ») ; avec cooldown ou une échéance absolue until, il décompte le temps pendant que la ligne se remplit de nouveau, puis relève tous les jetons. fail() passe par un mv-attempt-fail annulable ; reset(), restore(n) et sync({ remaining, max, until }) le gardent aligné sur le serveur, dont le décompte l’emporte toujours. for le relie au champ via aria-describedby (y compris l’input interne des custom elements), et chaque changement est annoncé poliment, la dernière tentative et le verrouillage de manière assertive.
| Catégorie | Feedback |
|---|---|
| Type | Web Component (<mv-figurines>) |
| Statut | bêta |
| Kit | Des états système honnêtes |
| 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
Installation
node scripts/add.mjs figurines --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["figurines"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/figurines/figurines.js, components/figurines/figurines.css.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<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() -->Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les 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>Référence culturelle
Ils étaient dix, Agatha Christie (1939, livre). Dix figurines se dressent sur la table de la salle à manger et l’une disparaît après chaque mort : chacun peut compter exactement combien il en reste et redoute la dernière. Dans l’interface, chaque tentative limitée est un petit jeton debout sur une ligne ; chaque échec en renverse un et laisse sa place vide, le texte dit ce qui se passe à zéro, et le dernier jeton est signalé avant le verrouillage.
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.