Rate Limit Notice
A 429 panel with a self-correcting cooldown clock — absolute-instant countdown, auto-unlock with milestone-only announcements, remaining quota with two urgency levels, consecutive-backoff labelling and an upgrade CTA.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/rate-limit-notice.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RateLimitNotice" component using
lucide-react (Hourglass, Layers, Ban, TriangleAlert, CircleCheck, RotateCw,
ArrowUpRight). It is the panel a client shows after an HTTP 429: a cooldown
clock that unlocks itself, the quota that is left, and a way out.
Contract
- export type RateLimitRetry =
| { at: string | number | Date; afterSeconds?: never }
| { afterSeconds: number; at?: never }
Two DIFFERENT semantics, deliberately not collapsed into one number:
`at` is the absolute instant from X-RateLimit-Reset; `afterSeconds` is the
relative delay from Retry-After, which is only true at the instant the server
emitted it. Document `at` as the recommended one: a relative count has to be
anchored to some local instant to become a countdown, and every millisecond
between "server wrote the header" and "we anchored it" is error you can never
recover. If the caller stores the seconds and mounts the panel later, the
error is unbounded.
- export type RateLimitAction =
| { label: string; href: string; onClick?: never }
| { label: string; onClick: () => void; href?: never }
Used for the upgrade CTA. Giving neither must be a COMPILE error — that is
what stops this button from ever shipping as a dead affordance.
- export interface RateLimitQuota { remaining: number; limit: number;
lowThreshold?: number } — lowThreshold defaults to max(1, ceil(limit * 0.1)).
limit <= 0 means "ceiling unknown": drop the meter, keep the count.
- Props on a forwardRef div extending
Omit<React.HTMLAttributes<HTMLDivElement>, "children">: retry; title?;
description?; attempt = 1 (clamped to an integer >= 1); scope?; quota?;
onRetry?; retrying = false; upgrade?; onUnlock?; announceAt = [60, 30, 10, 5];
locale = "en-US"; now? (first-paint reference instant only); labels?
(Partial of a fully typed label bag — every string and every sentence
template is overridable for i18n). className merged with cn(), rest spread.
Behavior
- ONE arm = one cooldown window, keyed by (resolved target, attempt). On arm,
resolve the target ONCE to an absolute epoch instant: `at` is parsed as-is,
`afterSeconds` is anchored to Date.now() inside the effect (never during
render). Store the window length so the meter has a denominator.
- Every tick recomputes `remaining = max(0, target - Date.now())`. NEVER
`previous - 1`. A self-decrementing interval loses almost every tick in a
throttled background tab: with a simulated 30-second suspension the
decrementing version showed 49s left when the truth was 20s (+29s of lie),
while recomputing from the target showed exactly 20s (0s). Also subscribe to
`visibilitychange` and recompute immediately on becoming visible, so a fully
frozen tab is corrected on the same frame the user comes back instead of
waiting for the next tick.
- Tick at 250ms, not 1000ms. Because each tick is a recomputation, denser
sampling cannot accumulate error; it only stops interval drift from
swallowing a whole second, and shortens the self-correction window.
- The elapsed meter has NO CSS width transition. Animating it would smooth the
post-wake correction into a one-second glide, i.e. lie about a jump that
really happened — and it makes prefers-reduced-motion trivially satisfied.
The only animation in the component is the retry spinner, which is
`animate-spin motion-reduce:animate-none`.
- At zero: stop the interval, flip to unlocked, announce once, call `onUnlock`
once per arm. DO NOT call `onRetry`. Every throttled client auto-firing at
t=0 rebuilds the stampede that caused the throttle; the retry is the user's
click. Say this in `onUnlock`'s JSDoc so nobody wires a request to it.
- Live region discipline: one permanently mounted `sr-only` span with
role="status" aria-live="polite" aria-atomic="true", rendered even when
empty (a region that appears together with its text is skipped by some
screen readers). It receives the arm message, one message per crossed
milestone, and the unlock message — never the ticking digits. Over a 60s
cooldown that is 5 announcements, not 60. Crossing several milestones at
once (after a suspension) consumes them all and speaks once. Announcements
use a SPOKEN duration ("2 minutes 5 seconds"), never the clock string
("2:05"), which screen readers read as "two zero five".
- The clock format is chosen from the WINDOW length, not the current value
(h:mm:ss over an hour, m:ss over a minute, else "45s"), so the display never
switches format mid-countdown and looks broken at the 60s boundary.
- `attempt > 1` renders a chip ("3rd limit in a row · this wait 1:00") and
changes the announcement. A longer second lock must read as a NEW event, not
as the same countdown restarting — that is the whole point of showing
backoff. attempt is part of the arm key, so bumping it re-arms even if the
target instant is unchanged.
- Quota urgency is exhausted / low / ok and is carried on three channels that
are not colour: the icon shape (Ban vs TriangleAlert vs none), a literal
word ("Quota exhausted" / "Running low"), and the meter outline (dashed
empty track when exhausted, solid track otherwise).
- The retry button uses aria-disabled + a guard inside the handler, NOT the
native disabled attribute. Native disabled drops it from the tab order and
blurs it at the exact moment it becomes usable; with aria-disabled a screen
reader user can already be parked on the button when the unlock is announced.
Its accessible name is static ("Try again") — the countdown lives beside it,
so a focused button's name does not change every second.
- Defensive numbers: an unparseable `at` (NaN) means "no cooldown" rather than
a permanent lock; negative afterSeconds clamps to 0; quota.remaining clamps
to >= 0 for display; announceAt drops non-finite and non-positive entries.
- Cleanup: the rAF, the interval and the visibilitychange listener are all
torn down by the same effect cleanup, and re-created on every re-arm.
- Render purity: never read Date.now() during render. `afterSeconds` gives a
pure first-paint value on its own; for `at`, pass `now` to make SSR and the
first client frame agree, otherwise the clock slot shows an em dash for one
frame. Ref writes happen in effects; state changes only inside async
callbacks (rAF / interval / events) or the render-phase "props changed"
adjustment.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border / bg-muted /
text-muted-foreground / bg-primary / text-primary-foreground /
bg-destructive/10 / text-destructive / ring-ring. No hex, no rgb(), no
oklch().
- Root is role="group" + aria-labelledby pointing at the title (NOT
role="alert" — the panel persists and re-renders; an alert region would
fight the polite status region for the same content).
- Layout: icon chip + title column, optional backoff chip, the clock block,
the quota block, then a wrapping action row. Every column carries min-w-0,
the title/description wrap with break-words, the scope line uses break-all
(it is usually an unbroken URL path), and button labels truncate. Verified
at 375 / 768 / 1440 with a 90-character unbroken path, an 80-character
title and a CJK scope: zero horizontal document scroll and no descendant
escaping its card's box.
- Decorative glyphs and both meters are aria-hidden; the numbers next to them
already say the same thing in text.
Customization levers
- Density: drop `sm:p-5`, the icon chip, or the description for an inline
strip inside a form footer; the component has no variant prop because
everything visual is reachable through className + which sub-blocks you pass.
- Sub-blocks are opt-in by prop: omit `quota` for a pure cooldown, omit
`upgrade` for a free product, omit `scope` when the limit is global, keep
`attempt` at 1 to hide the backoff chip entirely.
- Announcement cadence: `announceAt` is the whole policy. [30, 10] for a
terser reading, [] for arm-and-unlock only, [120, 60, 30, 10, 5] for long
windows. Never put the per-second value in there.
- Tick rate: TICK_MS is a display-smoothness knob, not a correctness one —
1000 is fine if you also keep the visibilitychange re-sync.
- Copy and i18n: every string and sentence template lives in `labels`,
including `spokenDuration` (swap in Intl.DurationFormat or your own
language) and `attemptChip`. `locale` only drives quota number formatting.
- Tone: the panel is deliberately card-neutral with a destructive-tinted icon
chip. For a louder treatment, move the destructive tint to the card border
and background; for a quieter one, replace it with bg-muted /
text-muted-foreground.
- Meter colours ride bg-primary (elapsed), bg-destructive (low quota) and
bg-foreground/40 (healthy quota); remap those three to re-theme both bars.Concepts
- Absolute reset instant vs relative retry-after —
X-RateLimit-Resetis a point in time and survives anything that happens to the page;Retry-Afteris a duration that is only true at the moment the server emitted it, so it must be anchored to a local clock and inherits every millisecond of lag on the way. Both are accepted; only one is recommended. - Recompute-from-target — each tick asks "target minus now", never "last value minus one". A self-decrementing timer silently under-counts whenever the browser throttles background timers, so the user comes back to a countdown that is minutes wrong while looking perfectly healthy.
- Wake re-sync — a fully frozen tab runs no timers at all, so the correction is hung off
visibilitychange: the moment the document becomes visible the clock is recomputed, before the next scheduled tick. - Auto-unlock without auto-retry — reaching zero is a state change (button live, one announcement, one
onUnlockcall), not a request. Firing the request automatically would synchronise every throttled client onto the same instant and rebuild the burst that caused the limit. - Milestone-only announcements — a ticking number inside a live region re-reads the whole panel every second. The region only speaks on arm, on crossing a milestone, and at zero, and it speaks a duration ("30 seconds") rather than a clock string.
- Backoff as a distinct event — the second lock is longer than the first, so it gets its own chip and its own announcement wording; without that, an escalating cooldown reads as the same timer inexplicably restarting.
- Urgency beyond colour — "quota exhausted" and "running low" differ by icon shape, by an explicit word, and by a dashed-versus-solid meter track, so the distinction survives greyscale, low vision and colour blindness.
Diff Confirm
A confirmation dialog that shows the change list first — grouped create/update/delete counts, expandable before → after rows, type-to-confirm past a destructive threshold, and a failure state that keeps the plan on screen.
Conflict Resolver
A field-level three-way merge for concurrent edits — base/mine/theirs per field, only the genuinely conflicting ones asked about, bulk fill with per-field override, and a merged record on submit.