errors
Rate Limit
A quota-exceeded state with a live countdown to when the user can send again.
Approaching weekly usage limitResets at 18:00
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface RateLimitProps {
label: string;
resetTime?: string;
resetAt?: Date;
upgradeLabel?: string;
onUpgrade?: () => void;
onDismiss?: () => void;
className?: string;
}
function useCountdown(resetAt?: Date) {
const getRemaining = () =>
resetAt ? Math.max(0, Math.floor((resetAt.getTime() - Date.now()) / 1000)) : null;
const [remaining, setRemaining] = React.useState(getRemaining);
React.useEffect(() => {
if (!resetAt) return;
const id = window.setInterval(() => setRemaining(getRemaining()), 1000);
return () => window.clearInterval(id);
}, [resetAt]);
if (remaining === null) return null;
const m = Math.floor(remaining / 60);
const s = remaining % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function RateLimit({
label,
resetTime,
resetAt,
upgradeLabel = "Get more usage",
onUpgrade,
onDismiss,
className,
}: RateLimitProps) {
const [visible, setVisible] = React.useState(true);
const countdown = useCountdown(resetAt);
const resetLabel = countdown ? `Resets in ${countdown}` : resetTime ? `Resets at ${resetTime}` : null;
function dismiss() {
setVisible(false);
onDismiss?.();
}
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.2, ease: "easeOut" }}
role="status"
aria-live="polite"
className={cn(
"flex w-full items-center gap-3 rounded-2xl border border-border bg-muted/50 px-4 py-2.5",
className
)}
>
<PulsingIcon />
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{label}</span>
{resetLabel && (
<span className="shrink-0 text-xs text-muted-foreground" aria-label={resetLabel}>
{resetLabel}
</span>
)}
</div>
{onUpgrade && (
<button
type="button"
onClick={onUpgrade}
className="shrink-0 rounded-lg border border-border bg-background px-3 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
>
{upgradeLabel}
</button>
)}
{onDismiss && (
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="size-4" aria-hidden />
</button>
)}
</motion.div>
)}
</AnimatePresence>
);
}
function PulsingIcon() {
return (
<span className="relative flex size-4 shrink-0 items-center justify-center" aria-hidden>
<span className="absolute size-3.5 animate-ping rounded-full bg-foreground/10" />
<span className="relative flex size-4 items-center justify-center rounded-full">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-foreground/60">
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
<path
d="M5.2 10.8a4 4 0 0 1 0-5.6"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path
d="M10.8 10.8a4 4 0 0 0 0-5.6"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path
d="M3.4 12.6a6.5 6.5 0 0 1 0-9.2"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
opacity="0.4"
/>
<path
d="M12.6 12.6a6.5 6.5 0 0 0 0-9.2"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
opacity="0.4"
/>
</svg>
</span>
</span>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Rate Limit
## Summary
A dismissible horizontal banner that warns the user their usage is approaching or has reached a quota limit. Shows a label, an optional reset time or live countdown, an upgrade CTA, and a dismiss control — all in a single compact row.
## When to use
- When the user is approaching or has hit a per-day, per-week, or per-month message quota.
- As a non-blocking notice inline in the UI — not a modal, not a toast, not a full page. The user should be able to read it and continue working (or dismiss it).
- When a reset time is known (either a specific clock time or a duration), always show it — it transforms a hard stop into a temporary state.
## When not to use
- Transient server errors or network failures — use the Generation Error pattern instead.
- Hard blocks where the user truly cannot proceed (account suspended, payment failed) — those warrant a modal or full-page state, not a dismissible banner.
- Per-request throttling (HTTP 429 with immediate retry-after < 60s) — show a brief inline message rather than a persistent banner.
## Anatomy
- **Pulsing signal icon**: concentric arcs around a center dot, with a subtle ping animation. Communicates "signal / broadcast / status" — not an error icon, not an alarm.
- **Label**: the usage limit message in medium-weight text. Should describe what limit was hit, not the raw technical limit.
- **Reset label**: muted text, inline with the label. Either a static clock time ("Resets at 18:00") or a live MM:SS countdown ("Resets in 4:23").
- **Upgrade CTA**: a bordered pill button with a short action label. Only shown when `onUpgrade` is provided.
- **Dismiss (×)**: icon-only button at the far right. Removes the banner with an exit animation.
## Behavior
- Enters with a short downward fade (opacity 0→1, y -6→0, 200ms ease-out).
- Exits with the reverse when dismissed.
- Countdown mode: a `setInterval` ticking every second drives the MM:SS display. The interval stops at 0 to avoid unnecessary renders. Callers are responsible for restoring access when the timer expires — this component does not re-enable the composer automatically.
- Static mode: `resetTime` is a pre-formatted string ("18:00"); the component renders it as-is.
- Dismissing via the × fires `onDismiss` and hides the component. Callers decide whether to re-show it (e.g. on next page load or after a threshold is crossed again).
## Reset label variants
- **`resetTime` (string)**: Pass a pre-formatted clock time. Use for quota resets tied to a known wall-clock time ("Resets at 18:00", "Resets at midnight").
- **`resetAt` (Date)**: Pass a future timestamp. The component derives a live countdown ("Resets in 4:23"). Use when the API returns an exact reset timestamp.
- If both are provided, `resetAt` takes precedence (live countdown is more informative).
- If neither is provided, no reset label is shown.
## Content guidelines
- Label: describe the limit in human terms, not API terms. "Approaching weekly usage limit" not "429 Too Many Requests" or "Rate limit: 10/10 used".
- Never show the raw numeric quota ("You've used 10 of 10 messages") — it frames the product negatively.
- Upgrade label: "Get more usage" or "Upgrade" — short, benefit-framed, not "Buy now" or "Go Pro".
- Reset label: "Resets at [time]" or "Resets in [MM:SS]" — consistent preposition, no parentheses.
## Accessibility
- The container has `role="status"` and `aria-live="polite"` so screen readers announce it when it appears without interrupting the user.
- The countdown span has an `aria-label` with the human-readable form ("Resets in 4 minutes 23 seconds") — screen readers don't read "4:23" legibly.
- The dismiss button has `aria-label="Dismiss"`.
- The pulsing icon is `aria-hidden` — it is decorative.
- Countdown ticks should NOT be in a live region themselves — announcing every second would be disruptive. The initial announcement of the full banner is sufficient.
## Related patterns
- Generation Error — for individual response failures, not quota states.
- Partial Response — for responses cut short by token limits rather than user quotas.