{"slug":"rate-limit","category":"errors","job":"recover","added":"2026-09-12T14:20Z","title":"Rate Limit","description":"A quota-exceeded state with a live countdown to when the user can send again.","url":"https://ai-patterns.app/patterns/errors/rate-limit","markdown":"https://ai-patterns.app/patterns/errors/rate-limit.md","json":"https://ai-patterns.app/api/patterns/errors/rate-limit","seenIn":[{"team":"Claude","source":"https://support.claude.com/en/articles/12466728-understand-claude-error-messages"}],"spec":"# Rate Limit\n\n## Summary\nA 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.\n\n## When to use\n- When the user is approaching or has hit a per-day, per-week, or per-month message quota.\n- 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).\n- 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.\n\n## When not to use\n- Transient server errors or network failures — use the Generation Error pattern instead.\n- Hard blocks where the user truly cannot proceed (account suspended, payment failed) — those warrant a modal or full-page state, not a dismissible banner.\n- Per-request throttling (HTTP 429 with immediate retry-after < 60s) — show a brief inline message rather than a persistent banner.\n\n## Anatomy\n- **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.\n- **Label**: the usage limit message in medium-weight text. Should describe what limit was hit, not the raw technical limit.\n- **Reset label**: muted text, inline with the label — it wraps onto its own line below the label when the banner is narrow. Either a static clock time (\"Resets at 18:00\") or a live MM:SS countdown (\"Resets in 4:23\").\n- **Upgrade CTA**: a bordered pill button with a short action label. Only shown when `onUpgrade` is provided.\n- **Dismiss (×)**: icon-only button at the far right. Removes the banner with an exit animation.\n\n## Behavior\n- Enters with a short downward fade (opacity 0→1, y -6→0, 200ms ease-out).\n- Exits with the reverse when dismissed.\n- The label is never truncated. It's the only thing that says what happened, so an ellipsis would hide the message itself. At narrow widths (phones, side panels, compact composers) the label wraps and the reset label drops to the next line, while the upgrade CTA and dismiss stay pinned to the right. When the text column would fall below ~11rem, the actions wrap onto their own row, right-aligned, rather than squeezing the message.\n- 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.\n- Static mode: `resetTime` is a pre-formatted string (\"18:00\"); the component renders it as-is.\n- 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).\n\n## Reset label variants\n- **`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\").\n- **`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.\n- If both are provided, `resetAt` takes precedence (live countdown is more informative).\n- If neither is provided, no reset label is shown.\n\n## Content guidelines\n- 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\".\n- Never show the raw numeric quota (\"You've used 10 of 10 messages\") — it frames the product negatively.\n- Upgrade label: \"Get more usage\" or \"Upgrade\" — short, benefit-framed, not \"Buy now\" or \"Go Pro\".\n- Reset label: \"Resets at [time]\" or \"Resets in [MM:SS]\" — consistent preposition, no parentheses.\n\n## Accessibility\n- The container has `role=\"status\"` and `aria-live=\"polite\"` so screen readers announce it when it appears without interrupting the user.\n- 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.\n- The dismiss button has `aria-label=\"Dismiss\"`.\n- The pulsing icon is `aria-hidden` — it is decorative.\n- 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.\n\n## Related patterns\n- **Generation Error** — Use it instead when one response failed for a reason other than a quota.\n- **Partial Response** — Use it instead when a response was cut short by token limits rather than usage quotas.\n- **Model Picker** — Use it as well so people can step down to a cheaper model instead of waiting.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface RateLimitProps {\n  label: string;\n  resetTime?: string;\n  resetAt?: Date;\n  upgradeLabel?: string;\n  onUpgrade?: () => void;\n  onDismiss?: () => void;\n  className?: string;\n}\n\nfunction getRemaining(resetAt?: Date) {\n  return resetAt ? Math.max(0, Math.floor((resetAt.getTime() - Date.now()) / 1000)) : null;\n}\n\nfunction useCountdown(resetAt?: Date) {\n  const [remaining, setRemaining] = React.useState(() => getRemaining(resetAt));\n\n  React.useEffect(() => {\n    if (!resetAt) return;\n    const id = window.setInterval(() => setRemaining(getRemaining(resetAt)), 1000);\n    return () => window.clearInterval(id);\n  }, [resetAt]);\n\n  if (remaining === null) return null;\n  const m = Math.floor(remaining / 60);\n  const s = remaining % 60;\n  return `${m}:${String(s).padStart(2, \"0\")}`;\n}\n\nexport function RateLimit({\n  label,\n  resetTime,\n  resetAt,\n  upgradeLabel = \"Get more usage\",\n  onUpgrade,\n  onDismiss,\n  className,\n}: RateLimitProps) {\n  const [visible, setVisible] = React.useState(true);\n  const countdown = useCountdown(resetAt);\n\n  const resetLabel = countdown ? `Resets in ${countdown}` : resetTime ? `Resets at ${resetTime}` : null;\n\n  function dismiss() {\n    setVisible(false);\n    onDismiss?.();\n  }\n\n  return (\n    <AnimatePresence>\n      {visible && (\n        <motion.div\n          initial={{ opacity: 0, y: -6 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: -6 }}\n          transition={{ duration: 0.2, ease: \"easeOut\" }}\n          role=\"status\"\n          aria-live=\"polite\"\n          className={cn(\n            \"flex w-full flex-wrap items-center gap-x-3 gap-y-2 rounded-2xl border border-border bg-muted/50 px-4 py-2.5\",\n            className\n          )}\n        >\n          {/* Never truncate: the label wraps and the reset label drops below it.\n              When even that can't fit, the actions wrap onto their own row. */}\n          <div className=\"flex min-w-[min(100%,11rem)] flex-1 items-center gap-3\">\n            <PulsingIcon />\n            <div className=\"flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5\">\n              <span className=\"text-xs font-medium text-pretty text-foreground\">{label}</span>\n              {resetLabel && (\n                <span className=\"shrink-0 text-xs text-muted-foreground\" aria-label={resetLabel}>\n                  {resetLabel}\n                </span>\n              )}\n            </div>\n          </div>\n\n          {(onUpgrade || onDismiss) && (\n            <div className=\"ml-auto flex shrink-0 items-center gap-3\">\n              {onUpgrade && (\n                <button\n                  type=\"button\"\n                  onClick={onUpgrade}\n                  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\"\n                >\n                  {upgradeLabel}\n                </button>\n              )}\n\n              {onDismiss && (\n                <button\n                  type=\"button\"\n                  onClick={dismiss}\n                  aria-label=\"Dismiss\"\n                  className=\"shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n                >\n                  <X className=\"size-4\" aria-hidden />\n                </button>\n              )}\n            </div>\n          )}\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction PulsingIcon() {\n  return (\n    <span className=\"relative flex size-4 shrink-0 items-center justify-center\" aria-hidden>\n      <span className=\"absolute size-3.5 animate-ping rounded-full bg-foreground/10\" />\n      <span className=\"relative flex size-4 items-center justify-center rounded-full\">\n        <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" className=\"text-foreground/60\">\n          <circle cx=\"8\" cy=\"8\" r=\"1.5\" fill=\"currentColor\" />\n          <path\n            d=\"M5.2 10.8a4 4 0 0 1 0-5.6\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.2\"\n            strokeLinecap=\"round\"\n          />\n          <path\n            d=\"M10.8 10.8a4 4 0 0 0 0-5.6\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.2\"\n            strokeLinecap=\"round\"\n          />\n          <path\n            d=\"M3.4 12.6a6.5 6.5 0 0 1 0-9.2\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.2\"\n            strokeLinecap=\"round\"\n            opacity=\"0.4\"\n          />\n          <path\n            d=\"M12.6 12.6a6.5 6.5 0 0 0 0-9.2\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.2\"\n            strokeLinecap=\"round\"\n            opacity=\"0.4\"\n          />\n        </svg>\n      </span>\n    </span>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}