Resend Code
A “Didn’t get a code?” control with a countdown ring, a Resend that unlocks at zero and growing back-off.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/resend-code.json'use client'
import { useEffect, useState } from 'react'
import './resend-code.css'
export interface ResendCodeProps {
destination: string
cooldown?: number
maxCooldown?: number
onResend?: () => Promise<void>
onChange?: () => void
fill?: 'dark' | 'light'
className?: string
}
const C = 2 * Math.PI * 9
/** Resend control with countdown ring and back-off. */
export function ResendCode({ destination, cooldown = 30, maxCooldown = 300, onResend, onChange, fill = 'dark', className }: ResendCodeProps) {
const [wait, setWait] = useState(cooldown)
const [left, setLeft] = useState(cooldown)
const [busy, setBusy] = useState(false)
const [sent, setSent] = useState(0)
useEffect(() => { setWait(cooldown); setLeft(cooldown) }, [cooldown])
useEffect(() => {
if (left <= 0) return
const t = setTimeout(() => setLeft((l) => l - 1), 1000)
return () => clearTimeout(t)
}, [left])
const resend = async () => {
setBusy(true)
await (onResend ? onResend() : new Promise<void>((r) => setTimeout(r, 600)))
const next = Math.min(maxCooldown, wait * 2)
setBusy(false); setSent((s) => s + 1); setWait(next); setLeft(next)
}
const mm = (s: number) => (s >= 60 ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : `${s}s`)
return (
<div className={['sd-rsc', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-rsc__to">Code sent to <b>{destination}</b>{onChange && <button type="button" onClick={onChange}>Change</button>}</div>
<div className="sd-rsc__row">
{left > 0 && <svg className="sd-rsc__ring" viewBox="0 0 22 22" aria-hidden><circle className="bg" cx="11" cy="11" r="9" /><circle className="fg" cx="11" cy="11" r="9" strokeDasharray={C} strokeDashoffset={C * (1 - left / wait)} /></svg>}
<span>{left > 0 ? `Resend available in ${mm(left)}` : 'Didn’t get it?'}</span>
<button type="button" className="sd-rsc__btn" disabled={left > 0 || busy} aria-label={left > 0 ? `Resend code, available in ${left} seconds` : 'Resend code'} onClick={resend}>{busy ? 'Sending…' : 'Resend'}</button>
</div>
<span className="sr" aria-live="polite">{left > 0 && left % 10 === 0 ? `${left} seconds until you can resend` : ''}</span>
{sent > 0 && <div key={sent} className="sd-rsc__ok" role="status">New code sent. Check {destination.includes('@') ? 'your inbox and spam' : 'your messages'}.</div>}
</div>
)
}.sd-rsc { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-rsc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-rsc button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rsc :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rsc .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rsc { width: min(100%, 300px); padding: 16px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-rsc__to { font-size: 13.5px; }
.sd-rsc__to b { font-weight: 650; }
.sd-rsc__to button { margin-left: 6px; font-size: 12.5px; color: var(--mute); text-decoration: underline; text-underline-offset: 3px; }
.sd-rsc__row { display: flex; align-items: center; gap: 10px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); font-size: 13px; color: var(--mute); }
.sd-rsc__ring { width: 22px; height: 22px; transform: rotate(-90deg); flex: none; }
.sd-rsc__ring circle { fill: none; stroke-width: 2.5; }
.sd-rsc__ring .bg { stroke: var(--soft); }
.sd-rsc__ring .fg { stroke: var(--fg); stroke-linecap: round; transition: stroke-dashoffset 1s linear; }
.sd-rsc__btn { margin-left: auto; height: 32px; padding: 0 14px; border-radius: 10px; font-size: 13px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); color: var(--fg) !important; transition: background-color 160ms, opacity 160ms; }
.sd-rsc__btn:not(:disabled) { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; animation: sd-rsc-pop 320ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-rsc__btn:disabled { opacity: 0.55; cursor: default; }
@keyframes sd-rsc-pop { from { transform: scale(0.85); } }
.sd-rsc__ok { margin-top: 10px; font-size: 12.5px; animation: sd-rsc-in 220ms ease; }
@keyframes sd-rsc-in { from { opacity: 0; transform: translateY(3px); } }
@media (prefers-reduced-motion: reduce) { .sd-rsc *, .sd-rsc *::before, .sd-rsc *::after { animation: none !important; transition: none !important; } }Overview
After sending, the link is disabled and a small ring drains with the seconds left. At zero it becomes Resend; each resend doubles the wait, capped, so people are not tempted to hammer it.
A sent confirmation names where the code went, with an option to change the destination.
When to use
- Email or SMS verification, magic links, 2FA.
When not to use
- Authenticator-app codes: nothing to resend.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/resend-code.json$
npx shadcn@latest add https://sundaeui.dev/r/resend-code.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/resend-code.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/resend-code.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy resend-code.tsx and resend-code.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ResendCode } from '@/components/sundae/resend-code'
<ResendCode />Props
| Prop | Type | Default |
|---|---|---|
destination*Where the code was sent (masked). | string | — |
cooldownFirst wait in seconds. | number | 30 |
onResendSend again. | () => Promise<void> | — |
onChangeChange destination. | () => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The disabled state says how long is left; the count is announced at 10-second steps only.
- Resent confirmation is announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rsc; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Resend Code free?
- Yes. Resend Code is free for personal and commercial projects, with full source on this page.
- How do I install Resend Code?
- Run npx shadcn@latest add https://sundaeui.dev/r/resend-code.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Resend Code support keyboard and screen readers?
- The disabled state says how long is left; the count is announced at 10-second steps only. Resent confirmation is announced politely.
- Does Resend Code respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →OTP Field
Forms · RefinedSign-in Card
Forms · RefinedTwo-Factor Setup
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined