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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/retry-countdown.jsonPrompt
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;
visibilitychangecloses 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
onRetryNowthe 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
maxAttemptsstops 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 style —
nonewakes every client that failed together at the same instant and rebuilds the outage;fullflattens the herd;equalspreads it while guaranteeing a minimum gap. The card names the strategy in its footnote so the choice is visible in review.
Overflow Tooltip
Text that reveals itself in a tooltip only when it is really truncated — width probe for a single line, height probe for a line clamp, and nothing at all when it fits.
Impersonation Banner
The bar support staff sees while acting as a customer — whose account this is, who is inside it, what the grant may write, a live countdown to the automatic cut-off, and an exit that is never blocked.