A resend-the-code button that disables itself for a countdown after each press, says how long is left in words, and re-enables itself, with a limit on how many times it can be used. Use this when resending is rate limited.
registry/patterns/resend-cooldown-button/resend-cooldown-button.tsx
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
export interface ResendCooldownButtonProps {
/** Send again. Reject to show an error and skip the cooldown. */
onResend?: () => Promise<void>
/** Wait between sends. Default 30. */
seconds?: number
/** Most sends allowed. Default 3. */
maxSends?: number
}
export function ResendCooldownButton({
onResend,
seconds = 30,
maxSends = 3,
}: ResendCooldownButtonProps) {
const [left, setLeft] = React.useState(0)
const [sends, setSends] = React.useState(0)
const [busy, setBusy] = React.useState(false)
const [error, setError] = React.useState(false)
const [announce, setAnnounce] = React.useState("")
React.useEffect(() => {
if (left <= 0) return
const timer = setTimeout(() => {
setLeft(left - 1)
// Say the time left at 10, 5 and 0 only, so the countdown does not chatter.
if ([10, 5].includes(left - 1))
setAnnounce(`You can resend in ${left - 1} seconds.`)
if (left - 1 === 0) setAnnounce("You can resend the code now.")
}, 1000)
return () => clearTimeout(timer)
}, [left])
async function resend() {
setBusy(true)
setError(false)
try {
await onResend?.()
setSends((n) => n + 1)
setLeft(seconds)
setAnnounce("Code sent.")
} catch {
setError(true)
} finally {
setBusy(false)
}
}
const exhausted = sends >= maxSends
return (
<div className="flex flex-col items-start gap-1">
<Button
type="button"
variant="outline"
disabled={busy || left > 0 || exhausted}
onClick={() => void resend()}
>
{busy
? "Sending..."
: left > 0
? `Resend code in ${left}s`
: "Resend code"}
</Button>
<p className="text-muted-foreground text-xs">
{exhausted
? "You have used all resends. Contact support if the code did not arrive."
: `${maxSends - sends} ${maxSends - sends === 1 ? "resend" : "resends"} left.`}
</p>
{error ? (
<p role="alert" className="text-destructive text-xs">
Could not send the code. Try again.
</p>
) : null}
<span role="status" aria-live="polite" className="sr-only">
{announce}
</span>
</div>
)
}