← Forms

Number Stepper Roll

Plus and minus buttons roll the number like an odometer: up for plus, down for minus. Only the digits that change move, the ones column first, and a new column slides open when 9 becomes 10. Hold a button to keep counting. At the limit the button fades and the number just bumps.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-number-stepper-roll and an aria-label: the buttons and number are built inside it.
  3. Set the range with data-min and data-max.

Options

Add any of these attributes to the same element.

data-valueThe starting number. Default 1.
data-minThe lowest number. Default 0.
data-maxThe highest number. Default 99.
data-stepHow much each press changes it. Default 1.
data-nameAdds a hidden input with this name, so the number posts with a form.
data-durationHow long each roll takes. Default 380 ms.
data-colorButton colour. Default #6a0dad.
data-autoplayIt presses plus and minus by itself while in view, and stops as soon as you use it.

The new number is read out politely, and the up and down arrow keys work too. Listen for the change event: e.detail.value is the new number.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Number Stepper Roll: a number between minus and plus buttons. Each press
    // rolls the number like an odometer: up for plus, down for minus, and
    // only the digits that change move, the ones column first. Hold a button
    // to keep counting. At the limit the button fades and the number gives a
    // little bump instead.
    // Usage: <div data-number-stepper-roll data-value="1" data-min="1" data-max="10" aria-label="Quantity"></div>
    // Optional: data-value (start, default 1), data-min (default 0), data-max
    // (default 99), data-step (default 1), data-name (adds a hidden input with
    // this name so the value posts with a form), data-color (button colour,
    // default #6a0dad), data-duration (roll time in ms, default 380),
    // data-autoplay (it presses plus and minus by itself while in view; it
    // stops as soon as you use it).
    // Listen for the "change" event: e.detail.value is the new number.
    var DEFAULTS = { value: 1, min: 0, max: 99, step: 1, color: '#6a0dad', duration: 380 };

    var MINUS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M6 12h12"/></svg>';
    var PLUS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M6 12h12M12 6v12"/></svg>';

    var css =
      '.nsr{display:inline-flex;align-items:center;gap:.35em;padding:.35em;border-radius:2em;background:#fff;' +
      'box-shadow:0 0 0 1px rgba(0,0,0,.08),0 6px 18px rgba(15,15,30,.07);vertical-align:middle;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}' +
      '.nsr-btn{flex:none;display:grid;place-items:center;width:2.4em;height:2.4em;margin:0;padding:0;border:0;border-radius:50%;' +
      'background:var(--nsr-soft);color:var(--nsr-color);font:inherit;cursor:pointer;transition:opacity .2s,background-color .2s;touch-action:manipulation}' +
      '.nsr-btn svg{width:1.1em;height:1.1em}' +
      '.nsr-btn:focus-visible{outline:2px solid var(--nsr-color);outline-offset:2px}' +
      '.nsr-off{opacity:.35;cursor:default}' +
      '.nsr-num{position:relative;display:flex;justify-content:center;min-width:2.2em;height:1.5em;padding:0 .2em;overflow:hidden;' +
      'font-size:1.45em;font-weight:700;line-height:1.5em;font-variant-numeric:tabular-nums;color:#18181b;text-align:center;' +
      '-webkit-mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent);mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent)}' +
      '.nsr-slot{position:relative;display:inline-block;height:1.5em;overflow:hidden}' +
      '.nsr-slot span{display:block}' +
      '.nsr-slot .nsr-out{position:absolute;left:0;top:0}' +
      '.nsr-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    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 num(v, f) { v = parseFloat(v); return isNaN(v) ? f : v; }
    function rgba(hex, a) {
      var m = /^#?([0-9a-f]{6})$/i.exec(hex);
      if (!m) return 'rgba(106,13,173,' + a + ')';
      var n = parseInt(m[1], 16);
      return 'rgba(' + (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
    }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var min = num(d.min, DEFAULTS.min), max = num(d.max, DEFAULTS.max), step = num(d.step, DEFAULTS.step) || 1;
      var value = Math.max(min, Math.min(max, num(d.value, DEFAULTS.value)));
      var duration = num(d.duration, DEFAULTS.duration);
      var decimals = step % 1 ? String(step).split('.')[1].length : 0;
      var color = d.color || DEFAULTS.color;
      var label = el.getAttribute('aria-label') || 'Quantity';
      el.classList.add('nsr');
      el.style.setProperty('--nsr-color', color);
      el.style.setProperty('--nsr-soft', rgba(color, 0.1));
      el.removeAttribute('aria-label');
      if (!mock) { el.setAttribute('role', 'group'); el.setAttribute('aria-label', label); }

      var tag = mock ? 'span' : 'button';
      var minus = document.createElement(tag), plus = document.createElement(tag);
      minus.className = plus.className = 'nsr-btn';
      minus.innerHTML = MINUS;
      plus.innerHTML = PLUS;
      var box = document.createElement('span');
      box.className = 'nsr-num';
      box.setAttribute('aria-hidden', 'true');
      el.textContent = '';
      el.appendChild(minus);
      el.appendChild(box);
      el.appendChild(plus);
      var live = null, hidden = null;
      if (!mock) {
        minus.type = plus.type = 'button';
        minus.setAttribute('aria-label', 'Decrease ' + label.toLowerCase());
        plus.setAttribute('aria-label', 'Increase ' + label.toLowerCase());
        live = document.createElement('span');
        live.className = 'nsr-sr';
        live.setAttribute('aria-live', 'polite');
        el.appendChild(live);
        if (d.name) {
          hidden = document.createElement('input');
          hidden.type = 'hidden';
          hidden.name = d.name;
          el.appendChild(hidden);
        }
      }
      function fmt(v) { return v.toFixed(decimals); }

      // Draw the number; when it changed, roll each changed digit (right first).
      function draw(from, to, dir, fast) {
        anime.utils.remove(box.querySelectorAll('span'));
        box.textContent = '';
        var a = fmt(from), b = fmt(to), n = Math.max(a.length, b.length);
        a = new Array(n - a.length + 1).join(' ') + a;
        b = new Array(n - b.length + 1).join(' ') + b;
        var place = 0, motion = dir && !reduced();
        for (var i = n - 1; i >= 0; i--) {
          var oc = a.charAt(i).trim(), nc = b.charAt(i).trim();
          var slot = document.createElement('span');
          slot.className = 'nsr-slot';
          var inn = document.createElement('span');
          inn.textContent = nc;
          slot.appendChild(inn);
          box.insertBefore(slot, box.firstChild);
          if (!motion || oc === nc) { if (!nc) slot.remove(); continue; }
          var out = document.createElement('span');
          out.className = 'nsr-out';
          out.textContent = oc;
          slot.appendChild(out);
          var dur = fast ? duration * 0.55 : duration, delay = place * (fast ? 25 : 45);
          place++;
          anime.animate(inn, { y: [dir > 0 ? '100%' : '-100%', '0%'], duration: dur, delay: delay, ease: 'out(3)' });
          anime.animate(out, { y: ['0%', dir > 0 ? '-100%' : '100%'], opacity: [1, 0.2], duration: dur, delay: delay, ease: 'out(3)' });
          if (!oc || !nc) {
            // A digit arriving or leaving (9 to 10): its column opens or closes.
            var w = (nc ? inn : out).offsetWidth || 10;
            anime.animate(slot, { width: nc ? [0, w] : [w, 0], duration: dur, delay: delay, ease: 'out(3)', onComplete: nc ? function () {} : (function (s) { return function () { s.remove(); }; })(slot) });
          }
        }
      }

      function sync() {
        minus.classList.toggle('nsr-off', value <= min);
        plus.classList.toggle('nsr-off', value >= max);
        if (!mock) {
          minus.setAttribute('aria-disabled', value <= min);
          plus.setAttribute('aria-disabled', value >= max);
          if (hidden) hidden.value = fmt(value);
        }
      }
      draw(value, value, 0);
      sync();

      function bump(dir) {
        if (reduced()) return;
        anime.animate(box, { x: [0, dir * 6, -dir * 3, 0], duration: 360, ease: 'out(2)' });
      }
      function change(dir, fast) {
        var next = Math.round((value + dir * step) * 1e6) / 1e6;
        if (next > max || next < min) { bump(dir); return false; }
        var from = value;
        value = next;
        draw(from, value, dir, fast);
        sync();
        if (live) live.textContent = fmt(value);
        el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: value } }));
        return true;
      }
      function tap(btn) {
        if (reduced()) return;
        anime.animate(btn, { scale: [0.86, 1], duration: 420, ease: 'outElastic(1, .5)' });
      }

      if (!mock) {
        [[minus, -1], [plus, 1]].forEach(function (pair) {
          var btn = pair[0], dir = pair[1], hold = null, repeat = null, held = false;
          function end() { clearTimeout(hold); clearInterval(repeat); }
          btn.addEventListener('pointerdown', function (e) {
            if (e.button) return;
            stop();
            held = false;
            end();
            hold = setTimeout(function () {
              held = true;
              repeat = setInterval(function () { if (!change(dir, true)) end(); }, 110);
            }, 450);
          });
          ['pointerup', 'pointerleave', 'pointercancel'].forEach(function (t) { btn.addEventListener(t, end); });
          btn.addEventListener('click', function () {
            stop();
            if (held) { held = false; return; }
            tap(btn);
            change(dir, false);
          });
        });
        el.addEventListener('keydown', function (e) {
          if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); stop(); change(e.key === 'ArrowUp' ? 1 : -1, false); }
        });
      }

      // Autoplay: presses plus a few times, then minus, like someone deciding.
      var timer = null, stopped = false, steps = [1, 1, 1, 0, -1, -1, 0, 1, 0, -1, -1, 0], start = value;
      function stop() { stopped = true; clearTimeout(timer); }
      function run(i) {
        if (stopped) return;
        var s = steps[i];
        if (s) {
          var btn = s > 0 ? plus : minus;
          tap(btn);
          if (!change(s, false)) { value = start; draw(value, value, 0); sync(); }
        }
        timer = setTimeout(function () { run((i + 1) % steps.length); }, s ? 650 : 1100);
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (entries[0].isIntersecting) { value = start; draw(value, value, 0); sync(); timer = setTimeout(function () { run(0); }, 700); }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-number-stepper-roll]').forEach(function (el) {
        if (el._nsr) return;
        el._nsr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-number-stepper-roll data-value="1" data-min="1" data-max="10" data-name="qty" aria-label="Quantity"></div>