Resend OTP Cooldown Timer — Free HTML CSS JS Verification Snippet
Resend OTP Cooldown Timer · Forms · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="rot-card">
<div class="rot-icon">✉</div>
<h3>Check your inbox</h3>
<p class="rot-sub">We sent a 6-digit code to <strong>[email protected]</strong></p>
<div class="rot-otp" id="rotOtp">
<input maxlength="1" inputmode="numeric" />
<input maxlength="1" inputmode="numeric" />
<input maxlength="1" inputmode="numeric" />
<input maxlength="1" inputmode="numeric" />
<input maxlength="1" inputmode="numeric" />
<input maxlength="1" inputmode="numeric" />
</div>
<button class="rot-verify" id="rotVerify">Verify code</button>
<div class="rot-resend-row">
<span id="rotIdle" hidden>Didn't get a code?</span>
<button class="rot-resend-link" id="rotResendBtn" hidden>Resend code</button>
<span class="rot-countdown" id="rotCountdown">Resend available in <strong id="rotSeconds">30</strong>s</span>
</div>
<p class="rot-status" id="rotStatus" hidden></p>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.rot-card{background:#fff;border-radius:18px;padding:32px 28px;width:100%;max-width:360px;text-align:center;box-shadow:0 4px 24px rgba(15,23,42,.08)}
.rot-icon{width:48px;height:48px;border-radius:50%;background:#eef2ff;color:#6366f1;font-size:20px;display:flex;align-items:center;justify-content:center;margin:0 auto 14px}
.rot-card h3{font-size:17px;font-weight:800;color:#0f172a;margin-bottom:6px}
.rot-sub{font-size:12.5px;color:#64748b;margin-bottom:20px}
.rot-sub strong{color:#334155}
.rot-otp{display:flex;gap:7px;justify-content:center;margin-bottom:18px}
.rot-otp input{width:38px;height:46px;text-align:center;font-size:19px;font-weight:700;font-family:monospace;color:#0f172a;border:2px solid #e2e8f0;border-radius:9px;outline:none;background:#f8fafc;transition:border-color .15s,box-shadow .15s}
.rot-otp input:focus{border-color:#6366f1;box-shadow:0 0 0 3px rgba(99,102,241,.12);background:#fff}
.rot-otp input.filled{border-color:#6366f1;background:#eef2ff}
.rot-verify{width:100%;padding:12px;background:#6366f1;color:#fff;border:none;border-radius:9px;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:14px;transition:background .15s}
.rot-verify:hover{background:#4f46e5}
.rot-resend-row{font-size:12.5px;color:#64748b;display:flex;align-items:center;justify-content:center;gap:5px;flex-wrap:wrap}
.rot-resend-link{background:none;border:none;color:#6366f1;font-weight:700;font-size:12.5px;cursor:pointer;font-family:inherit;padding:0}
.rot-resend-link:hover{text-decoration:underline}
.rot-countdown strong{color:#334155}
.rot-status{margin-top:12px;font-size:12.5px;font-weight:600;color:#059669;background:#ecfdf5;border-radius:8px;padding:8px}
.rot-status[hidden]{display:none}(function(){
var inputs = Array.prototype.slice.call(document.querySelectorAll('#rotOtp input'));
var verifyBtn = document.getElementById('rotVerify');
var idleSpan = document.getElementById('rotIdle');
var resendBtn = document.getElementById('rotResendBtn');
var countdownEl = document.getElementById('rotCountdown');
var secondsEl = document.getElementById('rotSeconds');
var statusEl = document.getElementById('rotStatus');
var DURATION = 30;
var remaining = DURATION;
var timerId = null;
inputs.forEach(function (inp, i) {
inp.addEventListener('input', function () {
inp.value = inp.value.replace(/\D/g, '');
inp.classList.toggle('filled', !!inp.value);
if (inp.value && i < inputs.length - 1) inputs[i + 1].focus();
});
inp.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !inp.value && i > 0) inputs[i - 1].focus();
});
});
function startCountdown() {
remaining = DURATION;
idleSpan.hidden = true;
resendBtn.hidden = true;
countdownEl.hidden = false;
secondsEl.textContent = String(remaining);
clearInterval(timerId);
timerId = setInterval(function () {
remaining -= 1;
secondsEl.textContent = String(remaining);
if (remaining <= 0) {
clearInterval(timerId);
countdownEl.hidden = true;
idleSpan.hidden = false;
resendBtn.hidden = false;
}
}, 1000);
}
resendBtn.addEventListener('click', function () {
inputs.forEach(function (inp) { inp.value = ''; inp.classList.remove('filled'); });
inputs[0].focus();
statusEl.hidden = false;
statusEl.textContent = 'A new code has been sent.';
setTimeout(function () { statusEl.hidden = true; }, 2500);
startCountdown();
});
verifyBtn.addEventListener('click', function () {
var code = inputs.map(function (i) { return i.value; }).join('');
statusEl.hidden = false;
if (code.length === 6) {
statusEl.style.color = '#059669';
statusEl.style.background = '#ecfdf5';
statusEl.textContent = 'Code ' + code + ' verified.';
} else {
statusEl.style.color = '#dc2626';
statusEl.style.background = '#fef2f2';
statusEl.textContent = 'Enter all 6 digits before verifying.';
}
});
startCountdown();
})();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Resend OTP Cooldown Timer — setInterval-Driven Rate Limiting UI

Every OTP verification screen needs a way to request a new code, but an unrestricted "resend" button invites accidental spam-clicking and makes it trivial to hammer an SMS or email provider's API. This snippet pairs a standard 6-digit OTP input with a visible cooldown timer that keeps the resend action disabled and counting down for 30 seconds before swapping it for a clickable link.
One countdown function, called twice
startCountdown() resets remaining to DURATION, shows the countdown text, hides the resend link, and starts a setInterval that decrements remaining and updates secondsEl.textContent once per second. The same function runs both on initial page load and again every time the user clicks resend — there's no duplicated timer logic between the "first send" and "resend" cases.
Swapping UI state instead of just disabling a button
Rather than a resend button that's merely disabled with grayed-out styling, the countdown text itself replaces the button entirely (countdownEl.hidden = false / resendBtn.hidden = true) — showing exactly how many seconds remain is more informative than a disabled control with no explanation, and it removes any temptation to keep clicking a dead button.
Clearing the previous interval before starting a new one
clearInterval(timerId) runs at the top of startCountdown() before the new setInterval is created. Without this, clicking resend while an old interval was somehow still running (or calling the function twice in quick succession) would stack multiple intervals decrementing remaining simultaneously, causing the countdown to run too fast.
Resend clears and refocuses the OTP boxes
Clicking resend doesn't just restart the timer — it also clears every input's value and filled class and moves focus back to the first box, matching the mental model that a new code invalidates whatever was previously typed, and puts the cursor exactly where the user needs to start typing the new code.
Independent verify feedback
The verify button's handler is entirely separate from the countdown — it just checks whether all six boxes are filled and shows a success or error status message, styled with inline color/background changes so the same statusEl element can represent both outcomes without two separate DOM nodes.
Customizing it
Change DURATION to match your provider's actual rate limit, replace the resend click handler's placeholder with a real API call, or add a maximum resend attempt counter that permanently disables the action after N tries.
Step by step
How to Use
- 1Paste HTML, CSS, and JSThe OTP input renders with a 30-second "Resend available in 30s" countdown already running.
- 2Watch the countdownThe number ticks down once per second until it reaches zero.
- 3Click Resend codeOnce the countdown ends, a clickable "Resend code" link appears in its place.
- 4Trigger a resendClicking it clears the OTP boxes, refocuses the first one, shows a confirmation message, and restarts the 30-second countdown.
- 5Change the durationIn the JS, edit the DURATION constant to match your SMS or email provider's actual rate limit.
- 6Wire up real APIsReplace the resend handler's placeholder logic with a fetch() call to your backend's resend endpoint, and connect verify() to your verification API.
Real-world uses
Common Use Cases
startCountdown() function driving both the first send and every resend.setInterval state management from a short and readable example anyone can adapt.Got questions?
Frequently Asked Questions
A disabled button with no explanation leaves users guessing why they can't click it or how long to wait. Showing "Resend available in 24s" and ticking it down gives a clear, specific reason and expectation, which reduces frustration and repeated clicking attempts.
Without clearing any existing interval first, calling startCountdown() a second time (for example from a resend click) would create a second setInterval running alongside the first, causing remaining to decrement twice per second and the countdown to finish in half the expected time.
Edit the DURATION constant near the top of the JS. It's used both to reset remaining at the start of every countdown and doesn't need to be changed anywhere else — the display and interval logic both read from the same variable.
Inside the resend button's click handler, replace or supplement the input-clearing logic with a fetch() POST to your resend endpoint. Only call startCountdown() again once that request succeeds, and show an error status instead if it fails, so the timer doesn't restart on a failed send.
As written, the countdown is purely in-memory and resets to the full duration on page reload. For a persistent cooldown across reloads, store the countdown's end timestamp in localStorage or sessionStorage and compute remaining as the difference from Date.now() when the page loads.