Rate Limit Meter
A live request-budget meter — a rolling-window gauge, a burst-dot timeline of recent calls, an escalating severity band, an expiry-aware forecast of when the limit lands, and a reset countdown once it does.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/rate-limit-meter.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RateLimitMeter" component (lucide-react
for icons; no charting library — the gauge is one SVG arc or one div, and the
timeline is a row of dots).
It answers two questions at a glance: how much of a request budget is left in
the current window, and how long that budget lasts at the pace it is being
spent. It is the BEFORE picture, not the 429 banner that comes after.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus children; the root spreads the remaining props.
- limit: number — requests allowed per window. Non-positive means "no limit
reported": render "42 / —", no bands, no forecast, never divide by zero.
- used?: number — budget consumed in the current window; server-authoritative
when present (X-RateLimit-Limit / -Remaining headers). Derived from `events`
when omitted.
- events?: readonly RateLimitEvent[] where
RateLimitEvent = { at: number; cost?: number; outcome?: "ok" | "throttled" }.
`at` is epoch ms; `cost` defaults to 1 — pass 0 for a call the gateway
rejected without billing it, or a weight for cost-weighted budgets;
`outcome: "throttled"` draws the request as a rejection on the timeline.
- windowS?: number = 60 — length of the budget window.
- windowMode?: "rolling" | "fixed" = "rolling".
- resetAt?: number — epoch ms the budget frees up. The cycle boundary in fixed
mode; an override for the derived next-slot instant in rolling mode.
- limited?: boolean — force the limited state (a 429 just came back).
Otherwise derived from used >= limit.
- now?: number — freeze the clock. When it is set NO timer is ever created,
which is what makes stories, tests and SSR snapshots reproducible.
- shape?: "bar" | "arc" = "bar". Ignored by variant="compact" (always a bar);
say so in the prop's JSDoc.
- variant?: "panel" | "compact" = "panel" — dashboard card vs one-line chip.
The compact variant drops the timeline and the footer by construction.
- warnAt?: number = 0.7, dangerAt?: number = 0.9 — band thresholds as
fractions; dangerAt is clamped so it can never sit below warnAt.
- tickMs?: number = 1000, buckets?: number = 40,
paceWindowS?: number = min(windowS, 15),
forecastHorizonS?: number = windowS * 5.
- label = "Requests", unit = "req", unitLabel = "requests" (spoken form).
- showTimeline / showForecast / showCountdown?: boolean = true.
- announce?: "milestones" | "off" = "milestones".
- onLimitedChange?: (limited: boolean) => void — fires on the EDGE only.
Behavior — a budget is a window, not a total
- Rolling mode: a request occupies the budget from `at` until `at + windowS`,
then frees its slot. Occupancy = the summed cost of events inside
(clock - windowS, clock].
- Fixed mode: the whole counter empties at `resetAt`, and the cycle is the
window that ends there. If the clock is already past `resetAt`, roll the
boundary forward by whole windows until the next response corrects it —
freezing on a stale boundary would keep a spent budget spent forever.
- Fixed mode with a server-reported `used`: once the clock passes the reset
instant that number is not "still 60 of 60", it is unknown. Fall back to the
events you can still see and label it "counters reset" rather than holding a
limit banner over a budget that has already refilled.
- `used` wins over the events when both are given; the difference between them
is budget the meter cannot age out, so the forecast treats it as permanent —
erring toward warning early, never late.
Behavior — the clock
- One interval, `tickMs`. It exists because the two things that change most are
ABSENCES: nothing re-renders when a request ages out of the window, and
nothing re-renders while a cooldown ticks down.
- It runs only while something can still change on its own — a live request
waiting to expire, or an instant to count down to — and never at all when
`now` is controlled. Clear it on unmount and on every dep change.
- Do NOT read Date.now() during render: hold it in state that starts null, so
the server render and the first client paint agree on a clock anchored to the
DATA (the newest event, or the start of the reported cycle), then let the real
clock take over on mount. Do not setState synchronously in the effect body
either — schedule the first reading.
- clock = max(tick, newest event) so a request landing between two beats pulls
the clock with it; the newest arrival can never sit in the future of the axis
it is drawn on.
Behavior — bands
- level = limited > danger (ratio >= dangerAt) > warn (>= warnAt) > calm.
- Escalation is never colour alone: each band carries a word ("Under budget" /
"Watch" / "Near limit" / "Limited"), an icon from `danger` upward, and a
number of its own. The tint is the fourth channel.
- Over the cap, the gauge clamps at 100% and the overflow is told in words
("+3 over the cap"). Never draw past the end of a scale.
Behavior — pace and the expiry-aware forecast (the part that is easy to get wrong)
- Pace = cost per second over the trailing `paceWindowS`. While history is
shorter than that window, measure over the history there IS, anchored ON the
oldest arrival — the intervals after it, not the fencepost itself, or four
requests fired in the first 200 ms read as 20/s forever. Under 1 s of base,
report no reading and print "Measuring pace…".
- A straight line from `used` to `limit` is the WRONG model for a rolling
window, because capacity comes back. Occupancy under continued arrivals is
survivors(t) + rate × min(t, windowS): a step function that drops at each
known expiry instant, plus arrivals that themselves start expiring after one
window. Walk those steps and return the first crossing.
- That also means the search can stop one window out — past it, occupancy is
pinned at the equilibrium rate × windowS. If the crossing has not happened by
then, the pace is sustainable and the honest answer is "Steady at 0.80 req/s
— stays under the limit", not an ETA that slides forever.
- Fixed mode is linear (nothing expires early), but compare the ETA with the
reset first: "At 17 req/min, the window resets before the limit".
- Print the crossing as "At <pace>, limited in ~<duration>", and switch the
pace unit by magnitude (req/s → req/min → req/h) so a 60-per-hour budget
never reads "0.02 req/s".
Behavior — countdown
- Rolling: the next free slot is the oldest live request's own expiry.
Fixed: the cycle boundary. `resetAt` overrides both.
- Always recompute `reset - clock` from the ABSOLUTE instant; never decrement a
counter, which drifts by exactly as long as the tab spent suspended.
- Show it whenever limited, always in fixed mode, and from the danger band
upward in rolling mode ("next slot frees in 8s"). Format with a ceiling, so
400 ms left never reads "0s".
Behavior — burst timeline
- Bucket the window into `buckets` equal TIME slices and draw one dot per
non-empty slice, its diameter scaled by sqrt(count) so a burst reads as a fat
dot. Empty slices draw a 1px axis segment — a quiet stretch must read as "no
traffic", not as a broken chart.
- Bucketing in time (not per event) is what stops 40 requests inside one second
from stacking on a single pixel, and keeps the DOM the same size for 8 events
or 600. Cap retained events (~600, keep the newest) so a chatty caller cannot
grow render cost without bound.
- Throttled slices are tinted destructive. In fixed mode the axis spans the
whole cycle, so mark "now" with a hairline; in rolling mode "now" is the
right edge.
- The lane is one role="img" with a sentence for its label ("48 requests in the
last 1m, 3 throttled, busiest slice 12"), not 40 announced children.
Behavior — announcements and the edge callback
- Every sentence is a function of the BAND, never of the changing number: a
polite live region re-fired on each request would talk over the page four
times a second. Speak "approaching", "limited" and "cleared", nothing else.
- "Cleared" needs the band it cleared INTO, not a bare flag: a rolling window
almost always clears into "near limit", so a flag reset by the next band
check would overwrite the one announcement that matters in the same commit.
- Track that milestone during render, on the update that carries the new band —
an effect would announce a paint late and cascade a second render to do it.
Fire onLimitedChange from an effect, on the edge only, with the ref seeded to
the current value so mounting into an already-limited budget stays silent.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border for the shell,
bg-muted for the gauge track, bg-chart-2 (calm) → bg-chart-3 (watch) →
bg-destructive (near limit and limited) for the fill and the status dot,
bg-chart-1 for request dots and bg-destructive for throttled ones,
text-muted-foreground for captions, bg-background for the threshold notches
cut into the track. No hex, no rgb(), no oklch().
- The gauge carries role="meter" with aria-valuemin/max/now and a full
aria-valuetext sentence. A progressbar would promise a task advancing toward
completion; this number goes back down on its own.
- The arc is one path swept 270° with the gap at the bottom, drawn twice (track
+ value) with pathLength={100} so strokeDasharray IS the percentage.
Threshold notches are short background-coloured lines across the stroke.
- Animate the fill with transition-[width] / transition-[stroke-dasharray] and
pulse the dot only in the danger band; both are motion-reduce:*-none, and the
reading stays complete with every animation off. Ship the keyframes in a
React 19 hoisted <style href precedence> so nothing needs a Tailwind config
edit.
- Format every number by hand (thousands separators, one or two decimals by
magnitude, "4m 20s" durations). Intl.NumberFormat resolves against the
runtime locale, and a "1,284" / "1 284" mismatch blows up hydration.
- Root is role="group" with an aria-label; data-level / data-limited /
data-slot attributes expose the state for consumer styling and tests.
Customization levers
- Bands: warnAt / dangerAt reshape the whole escalation — 0.5 / 0.8 for a
budget you must never actually hit, 0.9 / 0.99 for one you happily ride. The
four band words live in one object; translate them there.
- Window model: windowMode="fixed" + resetAt for hourly quotas (GitHub-style),
rolling for token buckets (most LLM gateways). windowS is the only other
input either needs.
- Sub-blocks: showTimeline / showForecast / showCountdown each drop a row. A
numbers-only meter is all three off; a composer footer is variant="compact".
- Density: buckets (24 for a sparse lane, 96 for a fine one), the lane height,
the arc size, and the panel's gap-3 / p-4 are the only spacing knobs.
- Cadence: tickMs 250 for a twitchy console, 5000 for a settings page. It costs
one interval either way, and the forecast is unaffected — pace is measured
over paceWindowS, not per beat.
- Forecast tuning: a short paceWindowS (5 s) reacts to bursts, a long one
(5 min) smooths a slow crawler; forecastHorizonS decides how far out an ETA
is still worth printing before it becomes "steady".
- Units: label / unit / unitLabel retarget it to tool calls, webhook
deliveries, SMS sends, seat-seconds — anything counted per window.
- Wiring: onLimitedChange is the integration point — pause a queue, disable a
send button, start jittered backoff. The meter deliberately never retries
anything itself.Concepts
- Rolling window vs fixed cycle — a sliding budget frees one slot at a time, exactly
windowSafter the request that took it; a fixed budget empties all at once at a boundary the server names. The same props feed both, but the countdown, the axis and the forecast each mean something different, which is why the mode is declared instead of guessed. - Expiry-aware forecast — projecting a straight line from "used" to "limit" is the classic mistake, because capacity comes back. The meter walks the known expiry instants under the assumed arrival rate, so a pace that fits inside the window reports "steady — stays under the limit" instead of an ETA that keeps sliding into the future.
- Equilibrium — under a constant rate, occupancy after one full window is pinned at
rate × windowS. That single fact makes the search finite: if the budget has not been crossed within one window, at this pace it never will be. - Burst bucketing — the timeline is sliced by TIME and one dot is drawn per slice with its area scaled to the count, so forty calls fired inside a second read as one fat dot instead of forty stacked on the same pixel — and the DOM costs the same for 8 events or 600.
- Escalation in four channels — approaching a limit changes the word, the icon, the number and only then the tint; a meter that escalates in colour alone is invisible to a good share of the people who most need to see it.
- Absence as an event — a request ageing out and a cooldown ticking down are both changes no prop carries, so the meter keeps one interval of its own; it stops the moment the window is empty, and never starts when the clock is controlled.
- The limited edge — the meter reports the crossing through
onLimitedChangeand stops there: pausing a queue or starting jittered backoff belongs to the caller, and a widget that silently retries on your behalf is the fastest way to turn one 429 into a hundred.
Content Credentials
A C2PA-flavoured provenance panel — a four-rung signature verdict, the signing issuer, the created/edited/exported chain, an expandable raw manifest, and a compact chip that opens the whole thing in a popover.
BYOK Setup
A bring-your-own-key card — configurable provider slots, a masked field that cleans what you paste and checks the shape per provider, a cancellable live test with latency or a reason, a storage disclaimer that never leaves, and removal behind a confirm.