Feedback

Retry Countdown

A backoff card that derives every wait from the schedule you pass — attempt against the cap, a ring draining to the next automatic attempt, a retry-now that skips the wait, the reason the last attempt failed, and a terminal state at the cap that offers your escape hatch instead of a dead button.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Ban, LoaderCircle, RotateCw, TriangleAlert } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

/**
 * Jitter strategy. The names and the maths follow the AWS Architecture Blog's
 * "Exponential Backoff and Jitter" — the same three strategies, over the same 1-based
 * `attempt` convention, that the `use-retry` hook draws with. The **option names differ**
 * (the hook's `BackoffOptions` calls them `baseDelay` / `maxDelay`, and its budget
 * `attempts`), so a client driving both adapts once at the call site rather than spreading:
 *

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/retry-countdown.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "RetryCountdown" component using
lucide-react (Ban, LoaderCircle, RotateCw, TriangleAlert) and the shadcn Button.
It is the card a client shows while it is backing off: which attempt failed out
of how many are allowed, how long until the next automatic one, why the last one
failed, and a way out.

Contract
- The component is handed a POLICY, never a timestamp. Props on a forwardRef div
  extending Omit<React.HTMLAttributes<HTMLDivElement>, "children">:
  attempt (required); maxAttempts = 5; baseMs = 500; factor = 2;
  maxDelayMs = 30000; jitter: "none" | "full" | "equal" = "full";
  random = Math.random; lastError?: string | Error; operation?: string;
  onRetryNow?: (trigger: "scheduled" | "manual") => void; onGiveUp?: () => void;
  labels? (Partial of a fully typed label bag — every string and every sentence
  template is overridable for i18n). className merged with cn(), rest spread.
- `attempt` is the 1-based index of the attempt that JUST FAILED. Document it
  hard: the host bumps it when a retry fails, never when one starts, because
  while this card has an attempt in flight it is displaying `attempt + 1`.
- Export the schedule as two pure functions so a host can chart it without
  failing a request first:
    retryBackoffCeiling(attempt, opts) = min(maxDelayMs,
                                             baseMs * factor ** (attempt - 1))
    retryBackoffDelay(attempt, opts)   = ceiling             for "none"
                                       = random() * ceiling  for "full"
                                       = ceiling/2 + random()*ceiling/2 for "equal"
  The names follow the AWS Architecture Blog's "Exponential Backoff and Jitter".
  Keep the jitter SEMANTICS and the 1-based `attempt` convention identical to the
  retry hook you ship, but do not assume the OPTION NAMES match: a hook that calls
  them `baseDelay` / `maxDelay` / `attempts` needs one adapter at the call site
  (`baseMs={o.baseDelay} maxDelayMs={o.maxDelay} factor={o.factor}
  jitter={o.jitter} maxAttempts={o.attempts}`). Spreading the hook's options
  object in instead compiles — excess properties survive a spread and land in the
  rest spread on the div — and silently drops the renamed keys, leaving the card
  counting down its own defaults. Clamp instead of throwing: attempt < 1 becomes
  1, factor < 1 becomes 1 (a shrinking backoff is never what was meant), a
  negative delay becomes 0, a non-finite prop falls back to its default.
  Short-circuit baseMs = 0 before the exponent: 0 * Infinity is NaN, and NaN in
  setTimeout means 0, which silently turns "backoff" into a busy loop. Clamp an
  injected random() to [0, 1] for the same reason.
- `random` exists so demos, tests and screenshots are reproducible: pass a
  seeded PRNG and the same waits replay every load.

Behavior
- ONE arm = one wait, keyed by (attempt, maxAttempts, the whole schedule, whether
  the card is read-only, the failure text). The jitter is drawn exactly ONCE per
  arm inside the effect — never during render, which would make render impure
  and desync SSR — and every tick after that recomputes
  `remaining = max(0, target - Date.now())`. Never
  "previous - 1": a self-decrementing timer loses nearly every tick in a
  throttled background tab and comes back minutes wrong while looking healthy.
  Also recompute on `visibilitychange`, because a frozen tab owes an attempt the
  moment it wakes.
- At zero the component calls onRetryNow("scheduled") exactly once, and the
  card switches to "attempt N running…" until the host changes a prop. Clicking
  "Retry now" calls onRetryNow("manual"), skipping whatever is left of the wait;
  a fired flag guarded in the handler AND checked in the tick makes a
  double-click, or a click landing on the same frame as the deadline, impossible
  to fire twice.
- If onRetryNow is NOT supplied the card is a read-only mirror: at zero it says
  "attempt N is due" and stops, and the "Retry now" button is not rendered at all.
  A component with nothing to call must not claim it started an attempt, and must
  not render a button that does nothing. It also stops DRAWING jitter — it is not
  the thing that fires the retry, so its own draw would be an unrelated sample of
  the same distribution, off from the host's draw by up to a whole ceiling. Read
  only + jitter therefore counts the un-jittered ceiling and labels it as the
  bound it is ("attempt 3 starts in up to 8.0s"), by passing a `bounded` flag to
  the `nextIn` and `waitAnnouncement` templates; with jitter "none" there is
  nothing to draw and the mirror is exact.
- At the cap (attempt >= maxAttempts) it stops for good: no timer is armed, the
  ring holds a struck-through state, the heading says how many attempts were
  spent — min(attempt, maxAttempts), so a host that bumps the counter after the
  final failure too cannot print "gave up after 6 attempts" over five pips — and
  the body says no further attempts will be made. "Retry now"
  disappears there — offering it would contradict the cap the card just
  asserted — and onGiveUp becomes the primary action. With no onGiveUp the card
  renders NO actions at all and explains the dead end in words, instead of
  parking a disabled button under the cursor.
- The schedule footnote is derived, never authored: "Backoff 1s × 2, full
  jitter, capped at 30s · then up to 8s, 16s" where the previewed ceilings are
  retryBackoffCeiling(a) for a in (attempt, maxAttempts), i.e. the waits that
  still exist. It empties itself at the cap because there are none left.
- Live-region discipline: two permanently mounted sr-only regions (polite
  status, assertive alert), rendered even when empty. The polite one speaks once
  per arm ("Retrying in 4 seconds. That will be attempt 4 of 5.") and once when
  an attempt starts; the alert one speaks only the terminal state. The ticking
  number NEVER enters a live region, and durations are spoken ("2 minutes 5
  seconds") rather than as a clock string, which is read as "two zero five".
- Cleanup: the rAF, the interval and the visibilitychange listener are torn down
  by the same effect cleanup, and rebuilt on every re-arm.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border / bg-muted /
  text-muted-foreground / stroke-muted / stroke-primary / text-primary /
  text-destructive / bg-destructive. No hex, no rgb(), no oklch().
- The clock is a draining SVG ring (viewBox 0 0 48 48, r=20, stroke-width 4,
  rotated -90 so it empties from twelve o'clock) via strokeDasharray +
  strokeDashoffset. It carries role="progressbar" with aria-valuenow as elapsed
  percent and aria-valuetext as the human sentence, but ONLY while waiting; at
  the cap and while an attempt runs there is no progress to report, so the ring
  becomes aria-hidden decoration with an icon in the middle.
- The ring has no CSS transition. A jump after a suspended tab is a real jump,
  and easing it into a one-second glide would lie about it — which is also why
  prefers-reduced-motion needs no special case here: it only drops the sample
  rate to 1s and the tenths off the readout. The one animation is the spinner
  (animate-spin motion-reduce:animate-none).
- The clock format is chosen from the WINDOW, not the current value (m:ss over a
  minute, whole seconds over ten, tenths below), so one wait never switches
  format halfway and looks broken at the boundary.
- A pip row (one dot per allowed attempt: spent = destructive, the one about to
  run = primary, the rest = muted) renders only up to 8 attempts; above that the
  heading's "attempt 12 of 40" carries it alone. The row is aria-hidden — the
  heading already says the same thing in words.
- Root is role="group" + aria-labelledby on the heading (not role="alert": the
  card persists and re-renders, and an alert region would fight the status
  region for the same content). "Retry now" uses aria-disabled + a guard in the
  handler, never the native attribute, so a screen-reader user parked on it when
  the wait ends does not lose focus at that exact moment; its accessible name
  stays static while the countdown changes beside it.
- Every column carries min-w-0, the error text wraps with break-words and the
  operation line uses break-all (it is usually an unbroken URL).

Customization levers
- Density: drop the ring for a single status line, or the schedule footnote for
  an end-user card that should not show engineering detail — both are leaf
  blocks with no logic attached.
- Which sub-blocks appear is prop-driven: omit `lastError` and the failure block
  is gone (the card invents nothing), omit `operation` for a global retry, omit
  `onGiveUp` and no escape hatch is offered.
- Schedule presets: 500ms × 2 capped at 30s for an API client, 1m × 3 capped at
  10m for webhook delivery, 5s × 1.5 with jitter "none" for a single background
  sync. Jitter is the only knob that changes correctness rather than looks —
  "none" synchronises every client that failed together.
- Copy and i18n: every string and sentence template lives in `labels`, including
  `escape` (relabel it "Contact support" / "Enter it manually" / "Cancel
  upload") and the three announcement templates.
- Tone: the accent rides bg-primary (ring, next-attempt pip) and text-destructive
  (spent pips, failure icon, the cap glyph). Remap those two to re-theme the
  whole card; move the destructive tint onto the card border for a louder
  treatment.
- Ring size lives in one class (size-16) plus the viewBox radius; the countdown
  digits scale with it.

Concepts

  • Policy in, delay out — the card is handed base / factor / jitter / cap, not a timestamp, and derives every wait itself. That is what lets it also preview the waits still ahead and stop exactly at the cap; a component handed times can do neither.
  • One draw per arm — jitter is randomness, so it is drawn once when the wait is armed and then held. Drawing it in render would give a different number on every re-render, desync SSR from hydration, and make the ring's denominator a moving target.
  • Recompute-from-target — each tick asks "target minus now". A decrementing timer silently under-counts whenever the browser throttles background timers, so the user returns to a countdown that is minutes wrong while looking perfectly healthy; visibilitychange closes the last gap.
  • Skip is not restart — "Retry now" fires the same attempt the schedule was going to fire, just earlier, and reports itself as "manual" so a host can log user-forced attempts apart from scheduled ones. One guard makes the deadline and the click mutually exclusive.
  • No callback, no claim — without onRetryNow the card has nothing to fire, so at zero it says the attempt is due rather than pretending it started one, and it renders no retry button at all. An affordance that cannot act should not exist. It stops drawing jitter there too: a card that is not firing the retry would otherwise print its own unrelated sample of the host's distribution as fact, so it counts the un-jittered ceiling and says "up to" — a bound instead of a guess.
  • The cap is terminal, not disabled — reaching maxAttempts stops the timer, drops the retry button and hands the floor to the escape hatch the host supplied. Where no escape was supplied it says in words that nothing further will happen, because a disabled button with no explanation is a dead end the user cannot read.
  • Jitter is a load-shedding decision, not a stylenone wakes every client that failed together at the same instant and rebuilds the outage; full flattens the herd; equal spreads it while guaranteeing a minimum gap. The card names the strategy in its footnote so the choice is visible in review.

On This Page