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
- Paste the snippet just before
</head>. - Add an empty element with
data-number-stepper-rolland anaria-label: the buttons and number are built inside it. - Set the range with
data-minanddata-max.
Options
Add any of these attributes to the same element.
data-value | The starting number. Default 1. |
data-min | The lowest number. Default 0. |
data-max | The highest number. Default 99. |
data-step | How much each press changes it. Default 1. |
data-name | Adds a hidden input with this name, so the number posts with a form. |
data-duration | How long each roll takes. Default 380 ms. |
data-color | Button colour. Default #6a0dad. |
data-autoplay | It 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.
<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><div data-number-stepper-roll data-value="1" data-min="1" data-max="10" data-name="qty" aria-label="Quantity"></div>