AI Patterns

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.