AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Ban, Timer, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Rate Limit Meter
 *
 * "How much of my request budget is left, and how long until I run out?" — a
 * live meter over a rolling (or fixed) window, the recent requests laid out on
 * a time axis, an escalating severity band, a forecast that knows capacity
 * comes BACK, and a countdown once the budget is spent.
 *

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/rate-limit-meter.json

Prompt

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 windowS after 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 onLimitedChange and 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.

On This Page