All patterns

Retry Backoff Countdown

A ring empties over the wait while the seconds count down to the next automatic attempt.

aicalmminimalautomatic · finite · intermediate · ~7.4s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

293 lines · react + motion only
import { useCallback, useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Retry Backoff Countdown
 *
 * A rate-limited request waiting out its backoff. A ring empties over the
 * wait, the seconds count down beside it, and each further attempt waits
 * longer than the last — so the pause is explained rather than endured.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color; the caution and success hues are
 * literal because they carry meaning.
 * Works with zero props; tune via `variant`, `waitsSeconds`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RetryBackoffCountdownProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Seconds to wait before each attempt, in order. Each entry is one
   *  step of the backoff; the last attempt is the one that succeeds. */
  waitsSeconds?: number[];
  /** Reason shown above the countdown. */
  reason?: string;
  /** Shown once the request goes through. */
  successLabel?: string;
  /** Fires when the sequence resolves. */
  onResolved?: () => void;
};

type VariantConfig = {
  /** Ring diameter in px. */
  size: number;
  /** Ring stroke weight in px. */
  stroke: number;
  /** Seconds the "sending" beat is held between countdown and result. */
  sendSeconds: number;
  /** px the status line travels as it changes. */
  travel: number;
};

// Quality rule: nothing springs and the digits never scale. A countdown
// that bounces on each tick turns a wait into a metronome, and a number
// that grows and shrinks is unreadable at exactly the moment someone is
// reading it. The ring empties linearly, because that is what the clock
// is doing.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A small ring beside the text. For a banner inside a busy view.
  subtle: { size: 30, stroke: 2.5, sendSeconds: 0.5, travel: 3 },
  // A readable dial. The all-purpose setting.
  default: { size: 38, stroke: 3, sendSeconds: 0.7, travel: 5 },
  // A larger dial for a full-width failure notice.
  playful: { size: 46, stroke: 3.5, sendSeconds: 0.9, travel: 8 },
};

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const CAUTION = "#E08A3C";
const SUCCESS = "#2E9E6B";

export default function RetryBackoffCountdown({
  variant = "default",
  waitsSeconds = [2, 4],
  reason = "Rate limited by the model provider",
  successLabel = "Request completed",
  onResolved,
}: RetryBackoffCountdownProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const attempts = Math.max(1, waitsSeconds.length);
  const [attempt, setAttempt] = useState(0);
  const [phase, setPhase] = useState<"waiting" | "sending" | "done">("waiting");
  const [remaining, setRemaining] = useState(waitsSeconds[0] ?? 1);

  const wait = waitsSeconds[attempt] ?? 1;

  const send = useCallback(() => {
    setPhase("sending");
  }, []);

  // The countdown is a real clock: one tick per second. The final tick
  // is what fires the send, so the transition happens on the same
  // timeline as the count rather than synchronously during a render.
  useEffect(() => {
    if (phase !== "waiting") return;
    const timer = setTimeout(() => {
      if (remaining <= 1) {
        send();
      } else {
        setRemaining((value) => value - 1);
      }
    }, 1000);
    return () => clearTimeout(timer);
  }, [phase, remaining, send]);

  useEffect(() => {
    if (phase !== "sending") return;
    const timer = setTimeout(
      () => {
        if (attempt + 1 >= attempts) {
          setPhase("done");
          return;
        }
        setAttempt((value) => value + 1);
        setRemaining(waitsSeconds[attempt + 1] ?? 1);
        setPhase("waiting");
      },
      cfg.sendSeconds * 1000
    );
    return () => clearTimeout(timer);
  }, [phase, attempt, attempts, waitsSeconds, cfg.sendSeconds]);

  useEffect(() => {
    if (phase === "done") onResolved?.();
    // Fires once when the sequence resolves, not on every parent render.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phase]);

  const done = phase === "done";
  const hue = done ? SUCCESS : CAUTION;
  const radius = (cfg.size - cfg.stroke) / 2;

  const status = done
    ? successLabel
    : phase === "sending"
      ? `Sending attempt ${attempt + 2 > attempts ? attempts : attempt + 1}`
      : `Retrying in ${remaining}s`;

  return (
    <div
      style={{
        width: 300,
        display: "flex",
        alignItems: "center",
        gap: 13,
        padding: "13px 15px",
        borderRadius: 13,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
        color: "inherit",
      }}
    >
      <div
        aria-hidden
        style={{
          position: "relative",
          flex: "0 0 auto",
          width: cfg.size,
          height: cfg.size,
        }}
      >
        <svg
          width={cfg.size}
          height={cfg.size}
          viewBox={`0 0 ${cfg.size} ${cfg.size}`}
          style={{ transform: "rotate(-90deg)", display: "block" }}
        >
          <circle
            cx={cfg.size / 2}
            cy={cfg.size / 2}
            r={radius}
            fill="none"
            stroke={tone(14)}
            strokeWidth={cfg.stroke}
          />
          <motion.circle
            // Keyed by attempt so each backoff step starts from a full
            // ring rather than continuing the previous one.
            key={`${attempt}-${phase === "waiting"}`}
            cx={cfg.size / 2}
            cy={cfg.size / 2}
            r={radius}
            fill="none"
            stroke={hue}
            strokeWidth={cfg.stroke}
            strokeLinecap="round"
            initial={{ pathLength: phase === "waiting" ? 1 : 0 }}
            // Reduced motion: the ring still reports the wait — it steps
            // to the fraction left once per second instead of sweeping.
            // The information survives; only the sweep is dropped.
            animate={{
              pathLength: done
                ? 1
                : phase === "waiting" && reduceMotion
                  ? Math.max(0, remaining - 1) / Math.max(1, wait)
                  : 0,
            }}
            transition={
              reduceMotion
                ? { duration: 0 }
                : { duration: phase === "waiting" ? wait : 0.3, ease: "linear" }
            }
          />
        </svg>

        <span
          style={{
            position: "absolute",
            inset: 0,
            display: "grid",
            placeItems: "center",
            fontSize: cfg.size * 0.34,
            fontWeight: 700,
            fontVariantNumeric: "tabular-nums",
            color: hue,
          }}
        >
          {done ? (
            <svg width={cfg.size * 0.42} height={cfg.size * 0.42} viewBox="0 0 16 16" fill="none">
              <path
                d="M3.6 8.4 6.6 11.4l5.8-6.8"
                stroke={SUCCESS}
                strokeWidth="1.8"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          ) : phase === "waiting" ? (
            remaining
          ) : (
            // The send itself has no duration to report, so the centre
            // holds a single slow breath rather than a rotating dial.
            <motion.span
              animate={reduceMotion ? { opacity: 0.8 } : { opacity: [0.35, 1, 0.35] }}
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { duration: 0.9, repeat: Infinity, ease: "easeInOut" }
              }
              style={{
                width: cfg.size * 0.2,
                height: cfg.size * 0.2,
                borderRadius: "50%",
                background: hue,
              }}
            />
          )}
        </span>
      </div>

      <div style={{ minWidth: 0, flex: 1 }}>
        <div style={{ fontSize: 12.5, fontWeight: 650 }}>
          {done ? "Recovered" : reason}
        </div>

        {/* The status line is text: it fades and slides a few pixels and
            keeps one size, so the sentence stays readable as it changes. */}
        <motion.div
          key={status}
          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.travel }}
          animate={{ opacity: 0.6, y: 0 }}
          transition={{ duration: reduceMotion ? 0.12 : 0.22, ease: "easeOut" }}
          role="status"
          style={{ marginTop: 2, fontSize: 11.5 }}
        >
          {status}
        </motion.div>

        <div
          aria-hidden
          style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 5 }}
        >
          {waitsSeconds.map((seconds, index) => (
            <span
              key={index}
              style={{
                height: 3,
                flex: seconds,
                borderRadius: 2,
                background:
                  index < attempt || done
                    ? `color-mix(in srgb, ${SUCCESS} 60%, transparent)`
                    : index === attempt
                      ? `color-mix(in srgb, ${CAUTION} 60%, transparent)`
                      : tone(12),
              }}
            />
          ))}
          <span style={{ fontSize: 10, opacity: 0.4, marginLeft: 2 }}>
            {waitsSeconds.map((seconds) => `${seconds}s`).join(" · ")}
          </span>
        </div>
      </div>
    </div>
  );
}

About this pattern

What a rate-limited request should look like while it waits. A ring empties linearly over the backoff — linear because that is what a clock does — the seconds tick beside it in tabular figures that never change size, and the bar underneath shows each step of the backoff getting longer than the last. Each attempt restarts the ring from full rather than continuing the previous one, so the wait is always read against the interval it belongs to. When the request finally lands, the ring redraws in the success hue instead of being replaced.

Rate limited by a providerAutomatic retry after a failureQueued request waiting its turnBackoff between polling attempts

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Dashboard

    Rate-limited actions show the wait remaining rather than an open-ended state.

Related patterns