Resend Countdown
A “Resend code” button that sleeps after each send. It reads “Resend code in 30s”, the number rolling down each second while a tinted fill drains out of it. At zero it wakes up with a little pop, solid and ready to press again.
We sent a 6-digit code to your phone.
How to use it
- Paste the snippet just before
</head>. - Add
data-resend-countdownto the button. It starts counting down straight away. - Send a new code when it fires its
resendevent.
Options
Add any of these attributes to the same element.
data-seconds | Length of the wait. Default 30. |
data-wait | The waiting label, with {s} for the number. Default “Resend code in {s}s”. |
data-color | Colour of the button. Default #6a0dad. |
data-ready | Start awake instead of counting down. |
data-autoplay | It presses itself a moment after waking while in view, without firing the event. Handy for demos. |
While it waits the button has aria-disabled="true" and ignores presses. animacious.resendCountdown(button) starts the wait again from code.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Resend Countdown: a "Resend code" button that sleeps for a while after
// each send. It reads "Resend code in 30s", the number rolling down each
// second while a tinted fill drains out of the button. At zero the button
// wakes up with a little pop, solid and ready to press again.
// Usage: <button data-resend-countdown>Resend code</button>
// Optional: data-seconds (length of the wait, default 30), data-wait (the
// waiting label, with {s} for the number, default "Resend code in {s}s"),
// data-color (colour of the button, default #6a0dad), data-ready (start
// awake instead of counting down), data-autoplay (it presses itself a
// moment after waking up while in view, without firing the event; handy
// for demos).
// Events: fires "resend" on the button when pressed while awake.
// Script use: animacious.resendCountdown(button) starts the wait again.
var DEFAULTS = { seconds: 30, wait: 'Resend code in {s}s', color: '#6a0dad' };
var css =
'.rsc{position:relative;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:#fff;background:var(--rsc-c);' +
'box-shadow:0 8px 22px color-mix(in srgb,var(--rsc-c) 30%,transparent);transition:background-color .35s,color .35s,box-shadow .35s}' +
':where(.rsc){font:inherit;font-weight:600;padding:.8em 1.5em;border-radius:999px;border:0}' +
'.rsc.rsc-wait{cursor:default;color:color-mix(in srgb,var(--rsc-c) 75%,#000);background:color-mix(in srgb,var(--rsc-c) 9%,#fff);' +
'box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rsc-c) 22%,transparent)}' +
'.rsc-clip{position:absolute;inset:0;z-index:-1;border-radius:inherit;overflow:hidden;pointer-events:none}' +
'.rsc-fill{position:absolute;inset:0;background:color-mix(in srgb,var(--rsc-c) 20%,#fff);transform-origin:0 50%;' +
'transform:scaleX(0);pointer-events:none}' +
'.rsc-labels{display:inline-grid}' +
'.rsc-labels>span{grid-area:1/1;white-space:nowrap;display:inline-flex;justify-content:center;align-items:baseline}' +
'.rsc-num{display:inline-grid;height:1.2em;line-height:1.2em;overflow:hidden;font-variant-numeric:tabular-nums;vertical-align:bottom}' +
'.rsc-num>span{grid-area:1/1;text-align:center}' +
'.rsc-ring{position:absolute;inset:0;border-radius:inherit;pointer-events:none;border:2px solid var(--rsc-c)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var seconds = +d.seconds || DEFAULTS.seconds;
el.classList.add('rsc');
el.style.setProperty('--rsc-c', d.color || DEFAULTS.color);
var fill = document.createElement('span');
fill.className = 'rsc-fill';
var clip = document.createElement('span');
clip.className = 'rsc-clip';
clip.setAttribute('aria-hidden', 'true');
clip.appendChild(fill);
var labels = document.createElement('span');
labels.className = 'rsc-labels';
var ready = document.createElement('span');
while (el.firstChild) ready.appendChild(el.firstChild);
var wait = document.createElement('span');
var parts = (d.wait || DEFAULTS.wait).split('{s}');
var num = document.createElement('span');
num.className = 'rsc-num';
var cur = document.createElement('span');
num.appendChild(cur);
wait.appendChild(document.createTextNode(parts[0].replace(/ $/, ' ')));
wait.appendChild(num);
if (parts[1]) wait.appendChild(document.createTextNode(parts[1].replace(/^ /, ' ')));
labels.appendChild(ready);
labels.appendChild(wait);
el.appendChild(clip);
el.appendChild(labels);
var waiting = false, endAt = 0, shown = -1, raf = 0;
function roll(n) {
var next = document.createElement('span');
next.textContent = n;
num.appendChild(next);
var old = cur;
cur = next;
if (reduced() || shown < 0) { old.remove(); return; }
anime.animate(old, { y: ['0%', '-100%'], opacity: [1, 0], duration: 320, ease: 'inOut(2)', onComplete: function () { old.remove(); } });
anime.animate(next, { y: ['100%', '0%'], opacity: [0, 1], duration: 320, ease: 'inOut(2)' });
}
function frame() {
var left = Math.max(0, endAt - Date.now());
var s = Math.ceil(left / 1000);
if (s !== shown) { roll(s); shown = s; }
// With reduced motion the fill steps once a second instead of gliding.
var f = reduced() ? s / seconds : left / (seconds * 1000);
fill.style.transform = 'scaleX(' + f + ')';
if (left > 0) raf = requestAnimationFrame(frame);
else wake();
}
function start() {
waiting = true;
shown = -1;
endAt = Date.now() + seconds * 1000;
el.classList.add('rsc-wait');
el.setAttribute('aria-disabled', 'true');
ready.setAttribute('aria-hidden', 'true');
wait.removeAttribute('aria-hidden');
anime.utils.set(ready, { opacity: 0 });
anime.utils.set(wait, { opacity: 1, y: 0 });
cancelAnimationFrame(raf);
frame();
}
function wake() {
waiting = false;
el.classList.remove('rsc-wait');
el.removeAttribute('aria-disabled');
wait.setAttribute('aria-hidden', 'true');
ready.removeAttribute('aria-hidden');
var t = reduced() ? 0 : 1;
anime.animate(wait, { opacity: 0, y: [0, -8], duration: 200 * t, ease: 'in(2)' });
anime.animate(ready, { opacity: [0, 1], y: [8, 0], duration: 320 * t, delay: 120 * t, ease: 'out(3)' });
if (!t) return;
anime.animate(el, { scale: [1, 1.08, 0.98, 1], duration: 560, ease: 'out(2)' });
var ring = document.createElement('span');
ring.className = 'rsc-ring';
ring.setAttribute('aria-hidden', 'true');
el.appendChild(ring);
anime.animate(ring, {
scaleX: [1, (el.offsetWidth + 22) / el.offsetWidth], scaleY: [1, (el.offsetHeight + 22) / el.offsetHeight], opacity: [0.55, 0],
duration: 700, ease: 'out(2)', onComplete: function () { ring.remove(); }
});
if (autoplay) setTimeout(function () { if (!waiting && visible && !el.matches(':hover')) start(); }, 1400);
}
el.addEventListener('click', function () {
if (waiting) return;
el.dispatchEvent(new CustomEvent('resend', { bubbles: true }));
start();
});
el._rscStart = start;
var autoplay = !reduced() && d.autoplay !== undefined && d.autoplay !== 'false';
var visible = true;
if (autoplay) {
visible = false;
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !waiting) setTimeout(function () { if (!waiting && visible) start(); }, 900);
}).observe(el);
}
if (d.ready !== undefined && d.ready !== 'false') {
anime.utils.set(wait, { opacity: 0 });
wait.setAttribute('aria-hidden', 'true');
} else {
start();
}
}
window.animacious = window.animacious || {};
window.animacious.resendCountdown = function (el) { if (el && el._rscStart) el._rscStart(); };
function init() {
document.querySelectorAll('[data-resend-countdown]').forEach(function (el) {
if (el._rsc) return;
el._rsc = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button id="resend" data-resend-countdown>Resend code</button>
<script>
document.getElementById("resend").addEventListener("resend", () => {
fetch("/otp/resend", { method: "POST" });
});
</script>