Exclusivebeta

Recharge — <mv-recharge>

A visible rate-limit and quota budget (token bucket) for anything metered: AI messages, API calls, SMS code resends, bulk actions, free-tier credits. consume(n) spends units and returns whether it was allowed (cancelable mv-consume, mv-deny when short); the meter drains with a crisp flare-and-collapse per segment, then regeneration starts only after delay without consumption (every new hit restarts the wait) and refills at a rate like "5/min", the unit being recharged filling live toward its leading edge. It shows remaining units, time to the next unit and to full, and a pre-empty warning state; a fixed-window mode (window, reset-at) restores everything at once. Triggers listed in for (each with its own data-recharge-cost) get aria-disabled, a described reason with the wait and an optional live countdown while the budget can't pay for them, several meters can gate one trigger (a 30 s cooldown plus 3 per hour), and auto-consume charges their clicks. sync() aligns it from a Response or RateLimit / X-RateLimit / Retry-After headers. Bar (segmented), ring and inline variants; time is derived from Date.now() anchors and can persist across reloads.

CategoryFeedback
TypeWeb Component (<mv-recharge>)
Statusbeta
Keywordsexclusive, culture, rate-limit, quota, token-bucket, budget, credits, cooldown, throttle, meter, usage, free-tier, ai-chat, api, ratelimit-headers, retry-after, resend-code, disabled-state, countdown

When to use

Avoid when

Install

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

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["recharge"], 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/recharge/recharge.js, components/recharge/recharge.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="rc-demo" style="width:min(100%,58rem);margin-inline:auto">
  <style>
    #rc-demo { display:grid; gap:1rem; align-content:start }
    #rc-demo .rc-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #rc-demo .rc-head { display:flex; align-items:center; justify-content:space-between; gap:1rem 1.5rem; flex-wrap:wrap; padding:1rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #rc-demo .rc-title { display:flex; align-items:center; gap:.75rem; min-width:0 }
    #rc-demo .rc-logo { display:grid; place-items:center; flex:none; width:2.25rem; height:2.25rem; border-radius:var(--mv-radius-lg); background:var(--mv-accent-subtle); color:var(--mv-accent) }
    #rc-demo .rc-logo svg { width:1.125rem; height:1.125rem }
    #rc-demo .rc-title strong { display:block; font-size:.9375rem; letter-spacing:-.01em }
    #rc-demo .rc-title span { color:var(--mv-fg-muted); font-size:.75rem }
    #rc-demo .rc-budget { flex:0 1 17rem; min-width:13rem }
    #rc-demo .rc-thread { display:grid; gap:.625rem; height:10.5rem; padding:1rem 1.125rem; overflow:auto; align-content:end }
    #rc-demo .rc-msg { margin:0; max-width:min(34rem,86%); padding:.5rem .75rem; border-radius:var(--mv-radius-lg); font-size:.8125rem; line-height:1.5 }
    #rc-demo .rc-msg[data-from="you"] { justify-self:end; background:var(--mv-accent); color:var(--mv-fg-on-accent); border-end-end-radius:var(--mv-radius-xs) }
    #rc-demo .rc-msg[data-from="ai"] { justify-self:start; background:var(--mv-bg-muted); color:var(--mv-fg); border-end-start-radius:var(--mv-radius-xs) }
    #rc-demo .rc-msg[data-typing] { color:var(--mv-fg-muted) }
    #rc-demo .rc-composer { display:flex; gap:.5rem; padding:0 1.125rem 1rem; flex-wrap:wrap }
    #rc-demo .rc-composer .mv-input { flex:1 1 16rem; min-width:0 }
    #rc-demo .rc-cost { margin-inline-start:.125rem; padding:0 .375rem; border-radius:var(--mv-radius-full); background:var(--mv-bg-muted); color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; line-height:1.25rem; font-variant-numeric:tabular-nums }
    #rc-demo .rc-controls { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
    #rc-demo .rc-log { flex:1 1 14rem; min-width:0; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); text-align:end; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #rc-demo .mv-choice { font-size:.8125rem }
    #rc-demo .rc-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,19rem),1fr)); gap:1rem }
    #rc-demo .rc-pane { display:grid; gap:.875rem; align-content:start; padding:1.125rem }
    #rc-demo .rc-pane h4 { display:flex; align-items:center; gap:.5rem; margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #rc-demo .rc-pane p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #rc-demo .rc-endpoint { display:flex; align-items:center; gap:.5rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); font:.75rem/1.3 var(--mv-font-mono); overflow:hidden; white-space:nowrap; text-overflow:ellipsis }
    #rc-demo .rc-endpoint b { color:var(--mv-success); font-weight:600 }
    #rc-demo .rc-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #rc-demo .rc-resp { min-height:4.25rem; margin:0; padding:.625rem .75rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg-muted); font:.6875rem/1.55 var(--mv-font-mono); white-space:pre-wrap }
    #rc-demo .rc-resp b { color:var(--mv-fg); font-weight:600 }
    #rc-demo .rc-resp[data-status="429"] b { color:var(--mv-danger) }
    #rc-demo .rc-code { max-width:12rem; font:600 1.0625rem/1 var(--mv-font-mono); letter-spacing:.35em; text-align:center }
    #rc-demo .rc-resend { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
    #rc-demo #rc-resend [data-recharge-time]::before { content:" in " }
  </style>

  <!-- Main: an AI assistant on a free plan, 10 messages, 6 per minute, 3 s recharge delay -->
  <section class="rc-card" aria-label="Assistant">
    <header class="rc-head">
      <span class="rc-title">
        <span class="rc-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v3M12 18v3M3 12h3M18 12h3M6 6l2 2M16 16l2 2M6 18l2-2M16 8l2-2"/><circle cx="12" cy="12" r="3"/></svg></span>
        <span><strong>Assistant</strong><span>Free plan · 10 messages, refills 6 per minute after a 3 s pause</span></span>
      </span>
      <mv-recharge id="rc-msg" class="rc-budget" capacity="10" refill="6/min" delay="3s" value="6" unit="message" label="Messages" for="rc-send rc-regen" auto-consume></mv-recharge>
    </header>
    <div class="rc-thread" id="rc-thread">
      <p class="rc-msg" data-from="you">Summarize the Q3 churn report for the Lisbon team in three bullets.</p>
      <p class="rc-msg" data-from="ai">Churn fell to 3.1% (down 0.8 pt). Most cancellations cite onboarding time. Annual plans retain twice as well as monthly ones.</p>
    </div>
    <form class="rc-composer" id="rc-form">
      <input class="mv-input" id="rc-prompt" aria-label="Message" value="Draft a reply to Amara Okafor about the delayed shipment">
      <button type="button" class="mv-button" data-variant="outline" id="rc-regen" data-recharge-cost="2">Regenerate <span class="rc-cost" aria-hidden="true">2</span><span class="mv-sr-only">(costs 2 messages)</span></button>
      <button type="submit" class="mv-button" id="rc-send">Send</button>
    </form>
    <div class="rc-controls">
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="rc-delay" checked> Recharge delay</label>
      <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="rc-burst">Send 4 in a row</button>
      <p class="rc-log" id="rc-log">6 of 10 messages left</p>
    </div>
  </section>

  <div class="rc-row">
    <!-- Ring: an API playground synced from RateLimit headers -->
    <section class="rc-card rc-pane" aria-label="API playground">
      <h4>API playground</h4>
      <div class="rc-endpoint"><b>GET</b> /v1/orders?status=open&amp;limit=25</div>
      <mv-recharge id="rc-api" variant="ring" capacity="12" refill="1/2s" delay="1s" value="5" label="Requests" for="rc-run" auto-consume></mv-recharge>
      <div class="rc-actions">
        <button type="button" class="mv-button" data-size="sm" id="rc-run">Send request</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="rc-429">Simulate a 429</button>
      </div>
      <pre class="rc-resp" id="rc-resp"><b>200 OK</b> · 41 ms
RateLimit-Limit: 12
RateLimit-Remaining: 5</pre>
    </section>

    <!-- Inline + hidden gate: an SMS code with a 30 s cooldown and 3 codes per hour -->
    <section class="rc-card rc-pane" aria-label="Phone verification">
      <h4>Verify your phone</h4>
      <p>Enter the 6-digit code we sent to +1 (415) 555-0132. It expires in 10 minutes.</p>
      <input class="mv-input rc-code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" aria-label="Verification code">
      <div class="rc-resend">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="rc-resend"><span>Resend code<span data-recharge-time></span></span></button>
        <mv-recharge id="rc-hourly" variant="inline" capacity="3" window="1h" reset-at="+48m" value="2" unit="code" label="Codes" for="rc-resend" auto-consume></mv-recharge>
      </div>
      <mv-recharge id="rc-cooldown" hidden capacity="1" refill="1/30s" value="0" unit="code" label="Code resend" for="rc-resend" auto-consume></mv-recharge>
      <p id="rc-sms">Code sent at <span id="rc-sent"></span>.</p>
    </section>
  </div>

  <script type="module">
    const $ = (id) => document.getElementById(id);
    const msg = $("rc-msg");
    const thread = $("rc-thread");
    const prompt = $("rc-prompt");
    const log = $("rc-log");
    const api = $("rc-api");
    const resp = $("rc-resp");
    const cooldown = $("rc-cooldown");
    const time = (d = new Date()) => d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    $("rc-sent").textContent = time(new Date(Date.now() - 6000));

    /* Assistant */
    const prompts = [
      "Draft a reply to Amara Okafor about the delayed shipment",
      "Translate the onboarding checklist into Portuguese",
      "Which accounts in Singapore renew before November 30?",
      "Turn these meeting notes into three action items",
      "Suggest a subject line for the October newsletter",
    ];
    const replies = [
      "Here’s a draft: “Hi Amara, your order left our Rotterdam warehouse today and should arrive by Friday, October 9.”",
      "Done: 12 steps translated, product names kept in English.",
      "Three accounts: Lumen Labs ($18,400), Kaya Foods ($9,200) and Straits Freight ($6,750).",
      "1. Kenji shares the pricing deck. 2. Priya books the vendor call. 3. Lucas updates the roadmap.",
      "“What’s new in October: faster exports and shared dashboards.”",
    ];
    let turn = 0;
    const bubble = (from, text) => {
      const p = document.createElement("p");
      p.className = "rc-msg";
      p.dataset.from = from;
      p.textContent = text;
      thread.append(p);
      while (thread.children.length > 6) thread.firstElementChild.remove();
      thread.scrollTop = thread.scrollHeight;
      return p;
    };
    const answer = (i, target) => {
      const p = target ?? bubble("ai", "Thinking…");
      p.dataset.typing = "";
      p.textContent = "Thinking…";
      setTimeout(() => { p.textContent = replies[i % replies.length]; delete p.dataset.typing; }, 650);
    };
    $("rc-form").addEventListener("submit", (e) => {
      e.preventDefault(); // the Send click already paid one message (auto-consume)
      bubble("you", prompt.value || prompts[turn % prompts.length]);
      answer(turn++);
      prompt.value = prompts[turn % prompts.length];
    });
    $("rc-regen").addEventListener("click", () => {
      const last = [...thread.querySelectorAll('[data-from="ai"]')].pop();
      if (last) answer(turn + 2, last);
    });
    $("rc-burst").addEventListener("click", () => {
      [0, 1, 2, 3].forEach((i) => setTimeout(() => msg.consume(1), i * 110));
    });
    $("rc-delay").addEventListener("change", (e) => { msg.delay = e.target.checked ? "3s" : "0s"; });

    msg.addEventListener("mv-consume", (e) => { log.textContent = `mv-consume · ${e.detail.amount} → ${e.detail.after} left`; });
    msg.addEventListener("mv-deny", (e) => { log.textContent = `mv-deny · needs ${e.detail.amount}, retry in ${Math.ceil(e.detail.retryIn / 1000)} s`; });
    msg.addEventListener("mv-empty", () => { log.textContent = "mv-empty · Send and Regenerate are disabled"; });
    msg.addEventListener("mv-refill", (e) => { log.textContent = `mv-refill · ${e.detail.remaining} of ${e.detail.capacity}`; });
    msg.addEventListener("mv-full", () => { log.textContent = "mv-full · 10 of 10 messages"; });

    /* API playground: every response re-syncs the meter from its headers */
    const show = (status, ms, headers) => {
      resp.dataset.status = String(status);
      const b = document.createElement("b");
      b.textContent = status === 200 ? "200 OK" : "429 Too Many Requests";
      resp.replaceChildren(b, ` · ${ms} ms\n` + Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n"));
    };
    api.addEventListener("mv-consume", (e) => {
      const remaining = e.detail.after;
      setTimeout(() => {
        const headers = { "RateLimit-Limit": "12", "RateLimit-Remaining": String(remaining) };
        show(200, 28 + Math.round(Math.random() * 40), headers);
        api.sync(headers);
      }, 220);
    });
    api.addEventListener("mv-deny", (e) => {
      resp.dataset.status = "blocked";
      resp.textContent = `Not sent: the client knows the budget is spent.\nRetry in ${Math.ceil(e.detail.retryIn / 1000)} s, no request wasted.`;
    });
    $("rc-429").addEventListener("click", () => {
      const headers = { "RateLimit-Limit": "12", "RateLimit-Remaining": "0", "Retry-After": "8" };
      show(429, 12, headers);
      api.sync(headers);
    });

    /* Phone verification: two gates on one button */
    cooldown.addEventListener("mv-consume", () => { $("rc-sent").textContent = time(); });
  </script>
</div>

Cultural reference

Halo: Combat Evolved — Bungie (2001, game). The player's energy shield drains with every hit and only starts regenerating after a short delay without taking damage, with a visible and audible recharge, so players learn to pause before re-engaging. In the UI, every metered action drains a visible budget whose units only begin to come back after a pause without consumption, and the triggers that would fail are disabled with the wait until the budget can pay for them.

API

Attributes

NameTypeDefaultDescription
capacitynumber10Maximum units (whole number ≥ 1). Raising it while full stays full; lowering it clamps the level. sync() updates it from the server's limit.
refillrate ("5/min", "1/30s", "100 per day", "0.5/s"){capacity}/minRegeneration rate in bucket mode, continuous (a unit is available as soon as it is whole). "0/min" disables refilling (a one-off allowance, topped up by sync(), refund() or reset()).
delaytime ("3s", "500ms", ms number)0sRegeneration starts only after this long without consumption; every consume() restarts the wait. The meter shows the hold phase (data-phase="hold", “Cooling down”, “Recharge in 2s”).
windowtime ("1h", "24h")Fixed-window mode: nothing refills gradually, all units come back at once at the end of each window, which then rolls forward. refill and delay are ignored.
reset-atISO date | epoch ms | epoch s | "+48m"Fixed-window mode: when the current window resets (defaults to now + window). With no window, the meter stays full after that reset until the next sync().
valuenumbercapacityInitial level (units available at first connection). Setting it later jumps the level there without events being announced.
warnnumber20% of capacity (at least 1)At or below this many units (and above 0), the meter is in its warning state (data-state="low", warning color, alert icon, “Running low”). 0 disables it.
forid listSpace-separated ids of the triggers this budget pays for. A trigger whose cost exceeds the level gets aria-disabled="true", data-recharge="blocked" and an aria-describedby reason (“Message limit reached. Available again in 12 seconds.”); its clicks (mouse, Enter, Space, implicit form submit) are blocked in the capture phase and explained. The original aria-disabled and describedby are restored when units come back or the meter is removed.
auto-consumebooleanA click on a for trigger consumes its cost first (through the cancelable mv-consume); the click proceeds only if it was paid. When several meters gate one trigger, the click must pass all of them before any is charged.
variantbar | ring | inlinebarbar: label and count, a segmented track and a status line. ring: a segmented dial with the count inside, label, status and times beside it. inline: a compact reading (mini dial, “2/3 left · resets in 48m”) for toolbars, forms and buttons. Add hidden to use the component as an invisible gate.
segmentsauto | numberautoNumber of cells. auto: one per unit up to 30 (bar) or 24 (ring), a continuous track beyond. A number groups units per cell (capacity 100, segments 20: 5 units per cell).
labelstringplural unit, capitalizedVisible label and accessible name of the meter (“Messages”, “API requests”).
unitstring ("message" or "query|queries")requestUnit noun used in the value text, reasons and announcements; the plural adds an s unless given after a |.
persiststringlocalStorage key: the level and timing survive reloads and are kept in sync across tabs (storage event). Ignored if storage is unavailable.
data-recharge-costnumber1On a trigger: units it costs. It is blocked as soon as the level is below its cost, not only at zero.
data-recharge-time"clock" | "short"clockOn an element inside a trigger: filled with the longest wait among the meters blocking it (“0:24”, or “24s” with short), emptied when available (:empty is display: none).
data-state / data-phasefull | ok | low | empty / idle | hold | charging | windowSet by the component (styleable). data-denied is added for 700 ms after a refused consumption, data-variant and data-continuous reflect the layout.

Properties

NameTypeDescription
remainingnumberWhole units available now (read-only, recomputed on read).
levelnumberExact fractional level, including the unit being recharged (read-only).
nextIn / fullInnumber (ms)Time until the next whole unit and until full (0 when full, Infinity without refill or reset).
state"full" | "ok" | "low" | "empty"Current budget state (read-only).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (keys: full, charging, hold, low, empty, available, left, next, fullIn, holdIn, holdNext, resetIn, rate, perWindow, noRefill, valuetext, vtFull, vtEmpty, vtNext, vtHold, vtReset, reason, reasonCost, reasonNever, announceLow, announceEmpty, announceEmptyReset, announceBack, announceFull; placeholders {n}, {cap}, {unit}, {units}, {Unit}, {Units}, {Label}, {time}, {cost}). English defaults.
capacity / refill / delay / window / resetAt / value / warn / for / autoConsume / variant / segments / label / unit / persistreflectedMirror the attributes. Changing refill or delay re-anchors the level first, so no unit is gained or lost.

Methods

NameDescription
consume(amount = 1, { source?, trigger? })Spends units if the level covers them: emits the cancelable mv-consume, drains the cells, restarts the recharge delay. Returns true if spent, false if cancelled or short (then mv-deny and a polite explanation).
canConsume(amount = 1)True if the units are available right now, without spending them.
refund(amount = 1)Gives units back (the request failed and should not count), without restarting the delay.
reset()Back to full now (window mode: a new window starts).
sync(source)Aligns the meter with the server: a Response, Headers, a header map or { limit, remaining, reset, retryAfter }. Reads RateLimit-Limit/-Remaining/-Reset (delta seconds), the structured RateLimit and RateLimit-Policy fields, X-RateLimit-* and X-Rate-Limit-* (epoch seconds or delta) and Retry-After. limit sets capacity; in bucket mode reset is read as the time the bucket is full again and Retry-After as the time of the next unit; in window mode either sets reset-at. Returns the parsed { limit, remaining, reset, retryAfter, window }.
MvRecharge.parseHeaders(source) / parseRateLimit(source)The header parser alone (static method and named export), e.g. to log or decide before syncing.

Events

NameDescription
mv-consumeCancelable, before units are spent. detail: { amount, remaining (before), after, source: "api" | "trigger", trigger }. preventDefault() vetoes it (consume() returns false, an auto-consume click is blocked).
mv-denyA consumption or a click on a blocked trigger was refused. detail: { amount, remaining, retryIn (ms, Infinity if never), source, trigger }.
mv-emptyThe last whole unit was spent (or a sync found none). detail: { retryIn, capacity }.
mv-refillWhole units came back (recharge, window reset, refund, sync). detail: { remaining, gained, capacity }.
mv-fullThe budget is full again. detail: { capacity }.

CSS classes

NameDescription
mv-recharge-meterThe role="meter" element holding the visual (children are presentational for assistive tech).
mv-recharge-head / -label / -count / -value / -ofBar header: label and count (“6 / 10”).
mv-recharge-track / -seg / -fill / -flashBar cells: --_f (0..1) is each cell's fill, data-next marks the cell being recharged, -flash is the consumption flare. The track sweeps once when recharging starts.
mv-recharge-dial / -ring / -center / -aside / -lineRing and inline dial (SVG arcs with -seg-track, -fill, -flash paths) and the ring's side texts.
mv-recharge-foot / -icon / -status / -timeStatus line: state icon, state word, next / full / reset times.

CSS variables

NameDefaultDescription
--mv-recharge-colorvar(--mv-accent)Fill color while the budget is healthy.
--mv-recharge-lowvar(--mv-warning)Fill and text tone of the warning state.
--mv-recharge-emptyvar(--mv-danger)Tone of the empty state (hatched cells, count, status) and of refusals.
--mv-recharge-trackcolor-mix(in oklab, var(--mv-fg) 10%, transparent)Empty cell color.
--mv-recharge-flashcolor-mix(in oklab, <tone> 45%, white)Color of the flare when units are spent or a unit completes.
--mv-recharge-height0.625remBar track height.
--mv-recharge-size5.5remRing diameter.

Accessibility

The visual is a role="meter" named by the label, with aria-valuemin 0, aria-valuemax the capacity, aria-valuenow the whole units left and an aria-valuetext that says what the bar shows (“3 of 10 requests left, next in 12 seconds”, “0 of 10 requests left, limit reached, recharge starts in 2 seconds, next in 14 seconds”); it is not a live region, so the per-second countdown never chatters. A single polite live region speaks only on thresholds: entering the warning state (“2 messages left.”), running out (“Message limit reached. Next message in 10 seconds.”), coming back from empty and becoming full again after a dip, plus the reason whenever a consumption or a click is refused. Gated triggers keep their focus and role: they get aria-disabled="true" (not disabled, so keyboard and screen reader users can still reach them and hear why) and an aria-describedby reason updated with the wait; Enter, Space, mouse clicks and implicit form submission are all blocked in the capture phase and explained, and the author's aria-disabled and describedby are restored afterwards. States never rely on color: each has a word and an icon (check, bolt, pause, alert, hourglass, clock), empty cells are hatched and the hold phase outlines the waiting cell. Reduced motion (OS or data-motion="reduce"): no flare, shake or sweep, cells jump once per second instead of filling continuously. Forced colors: cells use Highlight on Canvas with CanvasText outlines (dashed when empty). Time is derived from Date.now() anchors and re-read when the tab becomes visible, so a throttled background tab never shows a wrong budget; the frame loop only runs while a unit is actually recharging on screen.