A button that waits longer after each attempt and shows why, so "Resend code" and retries aren't tapped repeatedly by accident.
StopsResend tapped again and again
"use client"
import * as React from "react"
import { CooldownButton, clearCooldown } from "@/components/ui/cooldown-button"
const ID = "demo:resend-otp"
export function Demo() {
const [run, setRun] = React.useState(0)
const [sent, setSent] = React.useState(1)
return (
<div className="w-full max-w-xs space-y-3 rounded-xl border bg-background p-5">
<div className="space-y-1">
<p className="font-medium">Check your phone</p>
<p className="text-sm text-muted-foreground">We sent a 6-digit code to +91 ••••• 04210. Codes sent: {sent}.</p>
</div>
<CooldownButton
key={run}
id={ID}
startCoolingDown
cooldowns={[10, 20, 40]}
maxAttempts={4}
reason="SMS can take up to a minute to arrive."
onAction={() =>
new Promise<void>((resolve) =>
setTimeout(() => {
setSent((n) => n + 1)
resolve()
}, 600),
)
}
>
Resend code
</CooldownButton>
<p className="text-xs text-muted-foreground">
Refresh the page: the countdown carries on.{" "}
<button
type="button"
onClick={() => {
clearCooldown(ID)
setSent(1)
setRun((n) => n + 1)
}}
className="rounded-sm underline underline-offset-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Reset demo
</button>
</p>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uicooldown-button.tsx252 lines"use client" // Bollard UI — cooldown-button · MIT licence · https://bollard-ui.vercel.app/components/cooldown-button import * as React from "react" import { Hourglass, LoaderCircle } from "lucide-react" import { cn } from "@/lib/utils" interface CooldownState { /** Successful attempts so far. */ attempts: number /** Epoch ms when the button unlocks again. */ until: number } const storageKey = (id: string) => `bollard-ui:cooldown:${id}` function readState(id: string): CooldownState | null { try { const raw = sessionStorage.getItem(storageKey(id)) return raw ? (JSON.parse(raw) as CooldownState) : null } catch { return null } } function writeState(id: string, state: CooldownState) { try { sessionStorage.setItem(storageKey(id), JSON.stringify(state)) } catch { // Storage can be full or blocked; the in-memory cooldown still applies. } } /** Forget a cooldown, e.g. once the user has verified and the flow restarts. */ export function clearCooldown(id: string) { try { sessionStorage.removeItem(storageKey(id)) } catch { // Nothing to clear. } } /** Seconds to wait after the nth attempt (1-based). The last step repeats. */ export const cooldownFor = (attempts: number, steps: number[]) => steps[Math.min(attempts, steps.length) - 1] ?? 0 /** 65_000 → "1:05". */ export const formatClock = (ms: number) => { const total = Math.ceil(ms / 1000) return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}` } const variants = { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60 dark:hover:bg-destructive/70", outline: "border bg-background text-foreground hover:bg-accent dark:border-input dark:bg-input/30 dark:hover:bg-input/50", } as const const sizes = { default: "h-9 px-4", sm: "h-8 px-3" } as const const DEFAULT_COOLDOWNS = [30, 60, 120] export interface CooldownButtonProps extends Omit<React.ComponentProps<"button">, "id" | "onClick"> { /** Stable key for this action ("resend-otp:+91…"). The cooldown is stored under it for the tab's session. */ id: string /** Runs on click. Return a promise; a rejection doesn't count as an attempt. */ onAction: () => void | Promise<void> /** Seconds to wait after each attempt. The last value repeats for later attempts. */ cooldowns?: number[] /** Attempts allowed before the button locks for the session. */ maxAttempts?: number /** Treat mount as the first attempt (the code was sent when the page opened). */ startCoolingDown?: boolean /** Visual style of the button. */ variant?: keyof typeof variants /** Size of the button. */ size?: keyof typeof sizes /** Label while cooling down, given the remaining time ("0:42"). */ waitingLabel?: (remaining: string) => React.ReactNode /** Label while `onAction` is running. */ pendingLabel?: React.ReactNode /** Why the user has to wait, shown under the button during the cooldown. */ reason?: React.ReactNode /** Attempts left, shown once the button is ready again or when few are left. */ attemptsLabel?: (left: number, max: number) => React.ReactNode /** Read to screen readers when the cooldown ends. */ readyAnnouncement?: string /** Shown once every attempt is used. */ exhaustedMessage?: React.ReactNode /** Fallback when `onAction` rejects without an Error message. */ errorMessage?: string /** Class for the wrapper that holds the button and its hint. */ className?: string } /** * For "Resend code", "Retry" and other actions that cost something each time. * Each attempt waits longer (30s, 60s, 120s…), the cooldown survives a page * refresh in the same tab because it lives in sessionStorage as an absolute * deadline (a new tab or cleared storage starts fresh, so rate-limit on the * server too), and the * button locks with a clear message once the attempts run out. It stays * focusable while waiting (aria-disabled), so keyboard users keep their place. */ export function CooldownButton({ id, onAction, cooldowns = DEFAULT_COOLDOWNS, maxAttempts = 5, startCoolingDown = false, variant = "outline", size = "default", children, waitingLabel = (remaining) => `Resend in ${remaining}`, pendingLabel = "Sending…", reason = "We just sent it. Give it a moment to arrive.", attemptsLabel = (left, max) => `${left} of ${max} tries left.`, readyAnnouncement = "You can try again now.", exhaustedMessage = "You've reached the limit for this session. Contact support if it still hasn't arrived.", errorMessage = "That didn't go through. Try again.", className, ...props }: CooldownButtonProps) { const [state, setState] = React.useState<CooldownState | null>(null) const [now, setNow] = React.useState(0) const [pending, setPending] = React.useState(false) const [error, setError] = React.useState<string>() const hintId = React.useId() // Kept in a ref so an inline `cooldowns={[…]}` array doesn't re-run the restore effect. const cooldownsRef = React.useRef(cooldowns) React.useEffect(() => { cooldownsRef.current = cooldowns }) const commit = React.useCallback( (attempts: number) => { const next = { attempts, until: Date.now() + cooldownFor(attempts, cooldownsRef.current) * 1000 } writeState(id, next) setState(next) setNow(Date.now()) }, [id], ) // Restore after hydration; until then the button stays inert so a fast click can't slip through. React.useEffect(() => { const saved = readState(id) if (saved) { setState(saved) setNow(Date.now()) } else if (startCoolingDown) commit(1) else setState({ attempts: 0, until: 0 }) }, [id, startCoolingDown, commit]) const exhausted = !!state && state.attempts >= maxAttempts const remaining = state && !exhausted ? Math.max(0, state.until - now) : 0 const waiting = remaining > 0 // Tick against the stored deadline, so a throttled background tab can't drift. React.useEffect(() => { if (!waiting) return const timer = setInterval(() => setNow(Date.now()), 250) return () => clearInterval(timer) }, [waiting]) const inert = !state || waiting || exhausted || pending const left = maxAttempts - (state?.attempts ?? 0) const shown = pending ? "pending" : waiting ? "waiting" : "idle" const handleClick = async () => { if (inert || !state) return setError(undefined) setPending(true) try { await onAction() commit(state.attempts + 1) } catch (err) { setError(err instanceof Error && err.message ? err.message : errorMessage) } finally { setPending(false) } } return ( <div data-slot="cooldown-button" className={cn("inline-flex flex-col items-start gap-1.5", className)}> <button type="button" data-state={exhausted ? "exhausted" : shown} aria-disabled={inert || undefined} aria-busy={pending || undefined} aria-describedby={hintId} onClick={() => void handleClick()} className={cn( "inline-flex shrink-0 items-center justify-center rounded-md text-sm font-medium whitespace-nowrap tabular-nums shadow-xs outline-none transition-[color,background-color,box-shadow] focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-busy:cursor-progress", variants[variant], sizes[size], inert && !pending && "cursor-not-allowed opacity-50", )} {...props} > {/* All labels share one grid cell, so the button keeps its width as it counts down. */} <span className="grid"> {(["idle", "waiting", "pending"] as const).map((key) => ( <span key={key} aria-hidden={shown !== key || undefined} className={cn( "col-start-1 row-start-1 inline-flex items-center justify-center gap-2", shown !== key && "invisible", )} > {key === "idle" && children} {key === "waiting" && ( <> <Hourglass aria-hidden className="size-4" /> {waitingLabel(formatClock(remaining || cooldownFor(maxAttempts, cooldowns) * 1000))} </> )} {key === "pending" && ( <> <LoaderCircle aria-hidden className="size-4 animate-spin" /> {pendingLabel} </> )} </span> ))} </span> </button> <p id={hintId} className="text-xs text-muted-foreground empty:hidden"> {exhausted ? ( exhaustedMessage ) : waiting ? ( <> {reason} {left <= 2 && <> {attemptsLabel(left, maxAttempts)}</>} </> ) : state && state.attempts > 0 ? ( attemptsLabel(left, maxAttempts) ) : null} </p> <span aria-live="polite" className="sr-only"> {state && !waiting && !exhausted && state.attempts > 0 ? readyAnnouncement : ""} </span> {error && ( <p role="alert" className="text-xs text-destructive"> {error} </p> )} </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { CooldownButton, clearCooldown } from "@/components/ui/cooldown-button"
<CooldownButton
key={run}
id={ID}
startCoolingDown
cooldowns={[10, 20, 40]}
maxAttempts={4}
reason="SMS can take up to a minute to arrive."
onAction={() =>
new Promise<void>((resolve) =>
setTimeout(() => {
setSent((n) => n + 1)
resolve()
}, 600),
)
}
>
Resend code
</CooldownButton>What it catches
- Repeated taps on resend or retry, with a growing wait
- Unclear waiting, by showing a countdown and the reason
- Running out of attempts, with a clear locked message
- Clicks before hydration, by staying inert until the saved state is restored
Props
| Prop | Type | Default |
|---|---|---|
id* (required)Stable key for this action ("resend-otp:+91…"). The cooldown is stored under it for the tab's session. | string | — |
onAction* (required)Runs on click. Return a promise; a rejection doesn't count as an attempt. | () => void | Promise<void> | — |
cooldownsSeconds to wait after each attempt. The last value repeats for later attempts. | number[] | DEFAULT_COOLDOWNS |
maxAttemptsAttempts allowed before the button locks for the session. | number | 5 |
startCoolingDownTreat mount as the first attempt (the code was sent when the page opened). | boolean | false |
variantVisual style of the button. | keyof typeof variants | "outline" |
sizeSize of the button. | keyof typeof sizes | "default" |
waitingLabelLabel while cooling down, given the remaining time ("0:42"). | (remaining: string) => React.ReactNode | (remaining) => `Resend in ${remaining}` |
pendingLabelLabel while `onAction` is running. | React.ReactNode | "Sending…" |
reasonWhy the user has to wait, shown under the button during the cooldown. | React.ReactNode | "We just sent it. Give it a moment to arrive." |
attemptsLabelAttempts left, shown once the button is ready again or when few are left. | (left: number, max: number) => React.ReactNode | (left, max) => `${left} of ${max} tries left.` |
readyAnnouncementRead to screen readers when the cooldown ends. | string | "You can try again now." |
exhaustedMessageShown once every attempt is used. | React.ReactNode | "You've reached the limit for this session. Contact suppo… |
errorMessageFallback when `onAction` rejects without an Error message. | string | "That didn't go through. Try again." |
classNameClass for the wrapper that holds the button and its hint. | string | — |
* required. Also accepts the native props of <button> (className, aria-*, data-*…), passed through to it.
Accessibility
- Stays focusable while waiting (aria-disabled instead of disabled)
- aria-busy while pending and aria-describedby to the reason
- Becoming ready is announced in an aria-live="polite" region
- Errors and the exhausted message use role="alert"
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
This is a UX guard rail. Enforce on the server: Enforce rate limits and attempt counts on the server (per account and IP), and make paid actions idempotent.
- UI only: a new tab, another device or cleared storage bypasses the cooldown
- It reduces accidental repeats; it does not rate-limit or prevent double charges on its own
What it stops
Resend tapped again and again
How people describe it