Cooldown Button

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

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/cooldown-button.json

The files land in your project. They're yours to edit.

Usage

cooldown-button-usage.tsx
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

CooldownButton props
PropTypeDefault
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>—
cooldowns

Seconds to wait after each attempt. The last value repeats for later attempts.

number[]DEFAULT_COOLDOWNS
maxAttempts

Attempts allowed before the button locks for the session.

number5
startCoolingDown

Treat mount as the first attempt (the code was sent when the page opened).

booleanfalse
variant

Visual style of the button.

keyof typeof variants"outline"
size

Size of the button.

keyof typeof sizes"default"
waitingLabel

Label while cooling down, given the remaining time ("0:42").

(remaining: string) => React.ReactNode(remaining) => `Resend in ${remaining}`
pendingLabel

Label while `onAction` is running.

React.ReactNode"Sending…"
reason

Why the user has to wait, shown under the button during the cooldown.

React.ReactNode"We just sent it. Give it a moment to arrive."
attemptsLabel

Attempts 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.`
readyAnnouncement

Read to screen readers when the cooldown ends.

string"You can try again now."
exhaustedMessage

Shown once every attempt is used.

React.ReactNode"You've reached the limit for this session. Contact suppo…
errorMessage

Fallback when `onAction` rejects without an Error message.

string"That didn't go through. Try again."
className

Class 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

Also known as

resend otp · resend code · otp · rate limit · countdown timer · sms · magic link · throttle

Field type

Dependencies

Pairs well with