RetryButton
MD, under 1950B gzippedA retry control with visible exponential backoff that stays disabled while offline.
- Category
- Feedback & status
- Budget
- md, several states or a live subscription
- Runtime deps
- None
Preview
This is the exact fixture the conformance suite renders in CI. Switch the language to see the component mirror and reformat.
Install
npx shadcn@latest add https://kata-ui-rho.vercel.app/r/retry-button.jsonCopies the source into your project. Pulls in 5 primitives: cn, announce, use-locale, locale, use-network.
Source
"use client";
import { useEffect, useRef, useState } from "react";
import { announce } from "../lib/announce";
import { cn } from "../lib/cn";
import { useLocale } from "../lib/use-locale";
import { useNetwork } from "../lib/use-network";
export interface RetryButtonLabels {
retry: string;
/** `{seconds}` is replaced with the remaining wait. */
waiting: string;
offline: string;
}
const DEFAULT_LABELS: RetryButtonLabels = {
retry: "Try again",
waiting: "Retrying in {seconds}s",
offline: "Waiting for a connection",
};
export interface RetryButtonProps {
onRetry: () => void | Promise<void>;
/** First backoff delay in ms; doubles each consecutive failure. */
baseDelay?: number;
/** Ceiling for the backoff, so it never becomes an effective dead end. */
maxDelay?: number;
labels?: Partial<RetryButtonLabels>;
className?: string;
}
/**
* A retry control with visible, honest backoff.
*
* Retry buttons are usually either instant — inviting a user on a flaky
* connection to hammer a failing endpoint — or silently rate-limited, which
* reads as the button being broken. This shows the wait as a live countdown,
* announces when it becomes usable again, and stays disabled while offline
* rather than pretending a retry could succeed.
*
* The countdown is announced only when it reaches zero. A live region ticking
* once a second is unusable with a screen reader.
*/
export function RetryButton({
onRetry,
baseDelay = 2000,
maxDelay = 30_000,
labels: labelOverrides,
className,
}: RetryButtonProps) {
const labels = { ...DEFAULT_LABELS, ...labelOverrides };
const { online } = useNetwork();
const { direction, locale } = useLocale();
const [remaining, setRemaining] = useState(0);
const attempts = useRef(0);
useEffect(() => {
if (remaining <= 0) return;
const timer = setTimeout(() => {
const next = remaining - 1;
setRemaining(next);
if (next === 0) announce(labels.retry, "polite");
}, 1000);
return () => clearTimeout(timer);
}, [remaining, labels.retry]);
async function handleClick() {
const delay = Math.min(baseDelay * 2 ** attempts.current, maxDelay);
attempts.current += 1;
setRemaining(Math.round(delay / 1000));
await onRetry();
}
const waiting = remaining > 0;
const disabled = waiting || !online;
const label = !online
? labels.offline
: waiting
? labels.waiting.replace("{seconds}", new Intl.NumberFormat(locale).format(remaining))
: labels.retry;
return (
<button
type="button"
dir={direction}
onClick={handleClick}
disabled={disabled}
className={cn(
"rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600",
disabled ? "cursor-not-allowed opacity-60" : "hover:bg-neutral-100 dark:hover:bg-neutral-800",
"dark:border-neutral-700",
className,
)}
>
{label}
</button>
);
}
What CI checks
- Bundled, minified and gzipped against its tier budget
- Audited by axe in the state previewed above
- Rendered through react-dom/server with no browser globals
- Scanned for network calls, dangerous sinks, and unguarded animation