← Forms

One-time Code Input

A row of boxes for a verification code. Each digit springs into its box as you type and the focus hops along. Fill the last box and a wrong code shakes the row red before it clears, while the right one ripples green. Backspace, arrow keys and pasting a whole code all work.

The right code is 482916.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-one-time-code-input: the boxes are built inside it.
  3. Set data-code to check the code in the page, or listen for the complete event and check it yourself.

Options

Add any of these attributes to the same element.

data-codeThe right code. Without it, every full code counts as right unless your complete handler calls animacious.oneTimeCode.error(el).
data-lengthNumber of boxes. Default 6, or the length of data-code.
data-nameAdds a hidden input with this name, so the code posts with a form.
data-autoplayIt types a wrong code, then the right one, over and over while in view. It stops as soon as you use it.

Phones can fill it from the code in a text message (the first box has autocomplete="one-time-code"). animacious.oneTimeCode.reset(el) empties it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // One-time Code Input: a row of boxes for a verification code. Each digit
    // springs into its box as you type and the focus hops along; backspace,
    // arrow keys and pasting a whole code all work. Fill the last box and the
    // code is checked: a wrong one shakes the row and flashes red before it
    // clears, a right one ripples green.
    // Usage: <div data-one-time-code-input data-code="482916"></div>
    // Optional: data-length (number of boxes, default 6; taken from data-code
    // when that's set), data-code (the right code; without it every full code
    // counts as right unless you call animacious.oneTimeCode.error(el) from your
    // "complete" handler), data-name (a hidden input's name, so it posts with a
    // form), data-autoplay (it types a wrong code, then the right one, over
    // and over while in view; it stops as soon as you use it).
    // Listen for the "complete" event: e.detail.code is the full code.
    var DEFAULTS = { length: 6, demo: '482916' };
    var RED = '#dc2626', GREEN = '#16a34a';

    var css =
      '.otc{display:flex;justify-content:center;gap:0.45em}' +
      '.otc-box{position:relative;display:grid;place-items:center;box-sizing:border-box;width:1.9em;height:2.3em;margin:0;padding:0;' +
      'border:0.07em solid #d4d4d8;border-radius:0.35em;background:#fff;color:inherit;font:inherit;font-weight:700;text-align:center;' +
      'caret-color:transparent;outline:none;transition:border-color .2s,box-shadow .2s}' +
      '.otc-box.otc-on{border-color:var(--otc-accent);box-shadow:0 0 0 0.12em var(--otc-ring)}' +
      '.otc-digit{display:block;line-height:1}' +
      '.otc-hidden{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }

    function setup(el) {
      var d = el.dataset;
      var code = (d.code || '').replace(/\D/g, '');
      var n = code.length || parseInt(d.length, 10) || DEFAULTS.length;
      var mock = !!el.closest('[aria-hidden="true"]');
      el.classList.add('otc');
      el.style.setProperty('--otc-accent', '#6a0dad');
      el.style.setProperty('--otc-ring', 'rgba(106,13,173,0.15)');
      if (!mock) {
        el.setAttribute('role', 'group');
        if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Verification code');
      }

      var boxes = [];
      for (var i = 0; i < n; i++) {
        var box;
        if (mock) {
          box = document.createElement('span');
        } else {
          box = document.createElement('input');
          box.type = 'text';
          box.inputMode = 'numeric';
          box.maxLength = 1;
          box.autocomplete = i === 0 ? 'one-time-code' : 'off';
          box.setAttribute('aria-label', 'Digit ' + (i + 1) + ' of ' + n);
        }
        box.className = 'otc-box';
        el.appendChild(box);
        boxes.push(box);
      }
      var hidden = null;
      if (d.name && !mock) {
        hidden = document.createElement('input');
        hidden.type = 'hidden';
        hidden.name = d.name;
        el.appendChild(hidden);
      }

      var digits = [];
      var busy = false, done = false;

      // Show one digit: it springs up into its box.
      function show(i, ch) {
        var box = boxes[i];
        digits[i] = ch;
        if (mock) {
          box.innerHTML = ch ? '<span class="otc-digit">' + ch + '</span>' : '';
          if (ch && !reduced()) anime.animate(box.firstChild, { scale: [0.3, 1], y: ['0.5em', '0em'], opacity: [0, 1], duration: 520, ease: 'outElastic(1, .6)' });
        } else {
          box.value = ch || '';
          if (ch && !reduced()) {
            anime.animate(box, { scale: [{ to: 1.12, duration: 110, ease: 'out(2)' }, { to: 1, duration: 420, ease: 'outElastic(1, .5)' }] });
          }
        }
        if (ch && !reduced()) anime.animate(box, { y: [{ to: '-0.18em', duration: 120, ease: 'out(2)' }, { to: 0, duration: 380, ease: 'outBounce' }] });
        if (hidden) hidden.value = digits.join('');
      }

      function highlight(i) {
        boxes.forEach(function (b, j) { b.classList.toggle('otc-on', j === i); });
      }

      function paint(color) {
        boxes.forEach(function (b) {
          b.style.borderColor = color || '';
          b.style.color = color || '';
        });
      }

      function clearAll() {
        for (var i = 0; i < n; i++) show(i, '');
        paint('');
      }

      function check() {
        var full = digits.join('');
        if (full.length < n) return;
        busy = true;
        highlight(-1);
        var ok = !code || full === code;
        el._otcError = false;
        el.dispatchEvent(new CustomEvent('complete', { bubbles: true, detail: { code: full } }));
        if (el._otcError) ok = false;
        if (ok) success(); else error();
      }

      function success() {
        busy = done = true;
        paint(GREEN);
        if (reduced()) return;
        anime.animate(boxes, {
          y: [{ to: '-0.35em', duration: 180, ease: 'out(2)' }, { to: 0, duration: 520, ease: 'outBounce' }],
          delay: anime.stagger(60)
        });
        anime.animate(boxes, { boxShadow: ['0 0 0 0em rgba(22,163,74,0.35)', '0 0 0 0.35em rgba(22,163,74,0)'], duration: 800, delay: anime.stagger(60, { start: 120 }), ease: 'out(2)' });
      }

      function error() {
        busy = true;
        paint(RED);
        var reset = function () {
          clearAll();
          busy = false;
          if (!mock && el.contains(document.activeElement)) boxes[0].focus();
        };
        if (reduced()) { setTimeout(reset, 900); return; }
        var w = Math.max(6, el.offsetWidth * 0.03);
        anime.animate(el, {
          x: [{ to: -w }, { to: w * 0.8 }, { to: -w * 0.6 }, { to: w * 0.4 }, { to: -w * 0.2 }, { to: 0 }],
          duration: 520,
          ease: 'inOut(2)'
        });
        setTimeout(function () {
          anime.animate(boxes, { opacity: [1, 0.3], duration: 200, delay: anime.stagger(30, { from: 'last' }), ease: 'in(2)' });
          setTimeout(function () {
            reset();
            anime.utils.set(boxes, { opacity: 1 });
          }, 200 + 30 * n);
        }, 900);
      }

      // Typing into the real boxes.
      if (!mock) {
        boxes.forEach(function (box, i) {
          box.addEventListener('focus', function () {
            stop();
            // After a right code, tapping a box starts again.
            if (done) { done = busy = false; clearAll(); if (i) { boxes[0].focus(); return; } }
            // Always fill from the first empty box.
            var first = digits.slice(0, n).join('').length;
            if (i > first && !digits[i]) { boxes[Math.min(first, n - 1)].focus(); return; }
            highlight(i);
            box.select();
          });
          box.addEventListener('blur', function () { setTimeout(function () { if (!el.contains(document.activeElement)) highlight(-1); }); });
          box.addEventListener('input', function () {
            var v = box.value.replace(/\D/g, '');
            if (busy) { box.value = digits[i] || ''; return; }
            if (v.length > 1) { fill(v, i); return; }
            if (!v) { box.value = ''; digits[i] = ''; return; }
            paint('');
            show(i, v);
            if (i < n - 1) boxes[i + 1].focus(); else check();
          });
          box.addEventListener('keydown', function (e) {
            if (busy) return;
            if (e.key === 'Backspace') {
              e.preventDefault();
              if (digits[i]) show(i, '');
              else if (i > 0) { show(i - 1, ''); boxes[i - 1].focus(); }
            } else if (e.key === 'ArrowLeft' && i > 0) { e.preventDefault(); boxes[i - 1].focus(); }
            else if (e.key === 'ArrowRight' && i < n - 1 && digits[i]) { e.preventDefault(); boxes[i + 1].focus(); }
          });
          box.addEventListener('paste', function (e) {
            e.preventDefault();
            if (busy) return;
            fill((e.clipboardData || window.clipboardData).getData('text'), i);
          });
        });
      }

      // Several digits at once (a paste, or the phone's code suggestion).
      function fill(str, from) {
        var v = String(str).replace(/\D/g, '').slice(0, n - from).split('');
        paint('');
        v.forEach(function (ch, k) { setTimeout(function () { show(from + k, ch); }, reduced() ? 0 : k * 60); });
        setTimeout(function () {
          var next = from + v.length;
          if (next < n) boxes[next].focus();
          else { if (!mock) boxes[n - 1].blur(); check(); }
        }, reduced() ? 0 : v.length * 60 + 80);
      }

      // Autoplay: a wrong code, then the right one.
      var timer = null, stopped = false;
      function stop() { stopped = true; clearTimeout(timer); }
      if (on(d.autoplay) && !reduced()) {
        var right = code || (DEFAULTS.demo + '0000000000').slice(0, n);
        var wrong = right.split('').map(function (c, k) { return k === n - 2 ? String((+c + 3) % 10) : c; }).join('');
        var steps = [];
        var type = function (str) {
          str.split('').forEach(function (ch, k) {
            steps.push([function () { highlight(k); show(k, ch); if (k === n - 1) check(); }, k === n - 1 ? 2000 : 200 + Math.random() * 120]);
          });
        };
        steps.push([function () { busy = done = false; clearAll(); highlight(0); }, 700]);
        type(wrong);
        steps.push([function () { highlight(0); busy = false; }, 600]);
        type(right);
        steps.push([function () { highlight(-1); }, 1200]);
        var run = function (k) {
          if (stopped) return;
          steps[k][0]();
          timer = setTimeout(function () { run((k + 1) % steps.length); }, steps[k][1]);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (entries[0].isIntersecting) timer = setTimeout(function () { run(0); }, 500);
        }).observe(el);
      }

      el._otcReset = function () { busy = done = false; clearAll(); };
    }

    function init() {
      document.querySelectorAll('[data-one-time-code-input]').forEach(function (el) {
        if (el._otc) return;
        el._otc = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.oneTimeCode = {
      // Call from a "complete" handler to treat the code as wrong.
      error: function (el) { el._otcError = true; },
      reset: function (el) { if (el._otcReset) el._otcReset(); }
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-one-time-code-input data-length="6" data-name="code"></div>

<script>
  document.querySelector('[data-one-time-code-input]').addEventListener('complete', function (e) {
    if (e.detail.code !== '482916') animacious.oneTimeCode.error(e.target);
  });
</script>