← Buttons

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

  1. Paste the snippet just before </head>.
  2. Add data-resend-countdown to the button. It starts counting down straight away.
  3. Send a new code when it fires its resend event.

Options

Add any of these attributes to the same element.

data-secondsLength of the wait. Default 30.
data-waitThe waiting label, with {s} for the number. Default “Resend code in {s}s”.
data-colorColour of the button. Default #6a0dad.
data-readyStart awake instead of counting down.
data-autoplayIt 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.

1 · Snippet
<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>
2 · Your HTML
<button id="resend" data-resend-countdown>Resend code</button>
<script>
  document.getElementById("resend").addEventListener("resend", () => {
    fetch("/otp/resend", { method: "POST" });
  });
</script>