Wheel Picker
Spinning wheels like a phone’s time picker. Drag or flick a wheel and it spins with momentum, its numbers curving away over the top and bottom as if printed on a drum, then settles on the value under the highlight. Tap a value above or below to spin straight to it. Number wheels loop round.
How to use it
- Paste the snippet just before
</head>. - Add an empty element with
data-wheel-pickerand anaria-label: the wheels are built inside it. - List the wheels in
data-columns(it’s a time picker if you leave it out), and size it withfont-size.
Options
Add any of these attributes to the same element.
data-columns | The wheels, separated by ;. Each is a range like 1-12 or 00-59:5 (every 5; a leading zero pads the numbers), or a list like AM,PM. Default 1-12;00-59;AM,PM. |
data-value | Starting values in the same order, such as 7;30;AM. |
data-labels | A name for each wheel, for screen readers. Default Hour;Minute;AM or PM. |
data-name | Adds a hidden input with this name holding the values joined by spaces, so they post with a form. |
data-autoplay | The wheels spin to new values by themselves while in view. It stops as soon as you use one. |
Each wheel is a spin button: arrow keys, Page Up, Page Down, Home and End spin it, and the mouse wheel works too. Listen for the change event: e.detail.values is the list of chosen values.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Wheel Picker: spinning wheels like a phone's time picker. Drag or flick
// a wheel and it spins with momentum, its numbers curving away over the
// top and bottom as if printed on a drum, then settles on the nearest
// value under the highlight band. Tap a value above or below to spin to
// it, or use the mouse wheel. Number wheels loop round.
// Usage: <div data-wheel-picker aria-label="Alarm time"></div>
// Optional: data-columns (the wheels, separated by ";": a range such as
// "1-12" or "00-59:5" (every 5; a leading zero pads the numbers), or a
// list such as "AM,PM"; default "1-12;00-59;AM,PM"), data-value (the
// starting values in the same order, e.g. "7;30;AM"), data-labels (a name
// for each wheel, for screen readers, default "Hour;Minute;AM or PM"),
// data-name (adds a hidden input with this name holding the values
// joined by spaces, so they post with a form), data-autoplay (the wheels
// spin to new values by themselves while in view; it stops as soon as
// you use it).
// Each wheel is a spin button: arrow keys, Page Up/Down, Home and End work.
// Listen for the "change" event: e.detail.values is the array of values.
var DEFAULTS = { columns: '1-12;00-59;AM,PM', value: '7;30;AM', labels: 'Hour;Minute;AM or PM' };
var STEP = 22; // degrees between items on the drum
var SPAN = 4; // items drawn either side of the centre
var css =
'.whp{position:relative;display:inline-flex;justify-content:center;gap:.4em;box-sizing:border-box;padding:0 .8em;border-radius:1.1em;background:#fff;' +
'box-shadow:0 0 0 1px rgba(0,0,0,.07),0 10px 30px rgba(15,15,30,.09);color:#18181b;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}' +
'.whp-band{position:absolute;left:.5em;right:.5em;top:50%;height:2.2em;margin-top:-1.1em;border-radius:.6em;background:#f1f1f4;pointer-events:none}' +
'.whp-col{position:relative;min-width:2.8em;height:10.4em;cursor:grab;touch-action:none;outline:none;' +
'-webkit-mask-image:linear-gradient(transparent,#000 30%,#000 70%,transparent);mask-image:linear-gradient(transparent,#000 30%,#000 70%,transparent)}' +
'.whp-col:active{cursor:grabbing}' +
'.whp-col:focus-visible{-webkit-mask-image:none;mask-image:none;box-shadow:0 0 0 2px #6a0dad;border-radius:.6em}' +
'.whp-item{position:absolute;left:0;right:0;top:50%;height:2.2em;margin-top:-1.1em;line-height:2.2em;text-align:center;' +
'font-size:1em;font-variant-numeric:tabular-nums;color:#a1a1aa;white-space:nowrap;will-change:transform}' +
'.whp-item.whp-on{color:#18181b;font-weight:600}' +
'.whp-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 mod(a, n) { return ((a % n) + n) % n; }
function parseColumn(spec) {
var m = /^\s*(\d+)\s*-\s*(\d+)\s*(?::\s*(\d+))?\s*$/.exec(spec);
if (m) {
var a = parseInt(m[1], 10), b = parseInt(m[2], 10), step = parseInt(m[3], 10) || 1;
var width = m[1].charAt(0) === '0' && m[1].length > 1 ? Math.max(m[1].length, m[2].length) : 0;
var out = [];
for (var v = a; a <= b ? v <= b : v >= b; v += a <= b ? step : -step) {
var s = String(v);
while (s.length < width) s = '0' + s;
out.push(s);
}
return { items: out, loop: out.length > 6 };
}
return { items: spec.split(',').map(function (s) { return s.trim(); }).filter(Boolean), loop: false };
}
function setup(el) {
var d = el.dataset;
var mock = !!el.closest('[aria-hidden="true"]');
var specs = (d.columns || DEFAULTS.columns).split(';');
var starts = (d.value || (d.columns ? '' : DEFAULTS.value)).split(';');
var labels = (d.labels || (d.columns ? '' : DEFAULTS.labels)).split(';');
el.classList.add('whp');
el.textContent = '';
var band = document.createElement('span');
band.className = 'whp-band';
el.appendChild(band);
var hidden = null;
if (!mock) {
el.setAttribute('role', 'group');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Picker');
if (d.name) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = d.name;
el.appendChild(hidden);
}
}
var H = 1, R = 1;
function measure() {
H = parseFloat(getComputedStyle(el).fontSize) * 2.2 || 30;
R = H / 2 / Math.tan(STEP / 2 * Math.PI / 180);
}
measure();
var cols = specs.map(function (spec, ci) {
var p = parseColumn(spec);
var col = { items: p.items, loop: p.loop, n: p.items.length, pos: 0, tween: null, nodes: [] };
var start = p.items.indexOf((starts[ci] || '').trim());
col.pos = start < 0 ? 0 : start;
col.el = document.createElement('div');
col.el.className = 'whp-col';
for (var k = -SPAN; k <= SPAN; k++) {
var it = document.createElement('span');
it.className = 'whp-item';
it.setAttribute('aria-hidden', 'true');
col.el.appendChild(it);
col.nodes.push(it);
}
if (!mock) {
col.el.tabIndex = 0;
col.el.setAttribute('role', 'spinbutton');
col.el.setAttribute('aria-label', (labels[ci] || '').trim() || 'Wheel ' + (ci + 1));
}
el.appendChild(col.el);
return col;
});
function index(col) { var i = Math.round(col.pos); return col.loop ? mod(i, col.n) : Math.max(0, Math.min(col.n - 1, i)); }
function render(col) {
var base = Math.round(col.pos);
for (var k = -SPAN; k <= SPAN; k++) {
var node = col.nodes[k + SPAN], i = base + k, off = i - col.pos, a = off * STEP;
var valid = col.loop || (i >= 0 && i < col.n);
if (!valid || Math.abs(a) >= 90) { node.style.visibility = 'hidden'; continue; }
var rad = a * Math.PI / 180, c = Math.cos(rad);
node.style.visibility = '';
node.textContent = col.items[col.loop ? mod(i, col.n) : i];
node.style.transform = 'translateY(' + (R * Math.sin(rad)).toFixed(2) + 'px) scaleY(' + c.toFixed(3) + ')';
node.style.opacity = Math.pow(c, 1.6).toFixed(3);
node.classList.toggle('whp-on', Math.abs(off) < 0.5);
}
}
function values() { return cols.map(function (c) { return c.items[index(c)]; }); }
var last = values().join(' ');
function sync(quiet) {
var vals = values();
cols.forEach(function (c, i) {
if (mock) return;
c.el.setAttribute('aria-valuetext', vals[i]);
c.el.setAttribute('aria-valuenow', index(c));
c.el.setAttribute('aria-valuemin', 0);
c.el.setAttribute('aria-valuemax', c.n - 1);
});
if (hidden) hidden.value = vals.join(' ');
var joined = vals.join(' ');
if (!quiet && !mock && joined !== last) el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { values: vals } }));
last = joined;
}
cols.forEach(render);
sync(true);
function settle(col, target, duration, ease) {
if (col.tween) col.tween.pause();
if (!col.loop) target = Math.max(0, Math.min(col.n - 1, target));
col.target = target;
if (reduced()) { col.pos = target; render(col); sync(); return; }
col.tween = anime.animate(col, {
pos: target, duration: duration || 300 + Math.min(1100, Math.abs(target - col.pos) * 70), ease: ease || 'out(3)',
onUpdate: function () { render(col); },
onComplete: function () {
col.tween = null;
if (col.loop) col.pos = mod(Math.round(col.pos), col.n);
render(col);
sync();
}
});
}
if (!mock) {
cols.forEach(function (col) {
var drag = null, wheelTimer = null;
col.el.addEventListener('pointerdown', function (e) {
if (e.button) return;
stop();
e.preventDefault();
col.el.focus({ preventScroll: true });
col.el.setPointerCapture(e.pointerId);
if (col.tween) { col.tween.pause(); col.tween = null; }
drag = { y: e.clientY, pos: col.pos, moved: 0, samples: [[e.timeStamp, col.pos]] };
});
col.el.addEventListener('pointermove', function (e) {
if (!drag) return;
var dy = e.clientY - drag.y;
drag.moved = Math.max(drag.moved, Math.abs(dy));
var p = drag.pos - dy / H;
if (!col.loop) {
// Rubber band past either end.
if (p < 0) p = -(1 - Math.exp(p / 1.5)) * 1.2;
else if (p > col.n - 1) p = col.n - 1 + (1 - Math.exp(-(p - col.n + 1) / 1.5)) * 1.2;
}
col.pos = p;
render(col);
drag.samples.push([e.timeStamp, p]);
if (drag.samples.length > 6) drag.samples.shift();
});
function end(e) {
if (!drag) return;
var dr = drag;
drag = null;
if (dr.moved < 5 && e.type === 'pointerup') {
// A tap: spin to the item that was tapped.
var r = col.el.getBoundingClientRect(), dy = e.clientY - (r.top + r.height / 2);
var a = Math.asin(Math.max(-1, Math.min(1, dy / R))) * 180 / Math.PI;
settle(col, Math.round(col.pos + a / STEP), 380);
return;
}
var s = dr.samples, a0 = s[0], a1 = s[s.length - 1];
var v = a1[0] > a0[0] ? (a1[1] - a0[1]) / (a1[0] - a0[0]) : 0;
settle(col, Math.round(col.pos + v * 260));
}
col.el.addEventListener('pointerup', end);
col.el.addEventListener('pointercancel', end);
col.el.addEventListener('wheel', function (e) {
e.preventDefault();
stop();
if (col.tween) { col.tween.pause(); col.tween = null; }
col.pos += (e.deltaMode === 1 ? e.deltaY * 0.4 : e.deltaY / H * 0.6);
if (!col.loop) col.pos = Math.max(-0.4, Math.min(col.n - 0.6, col.pos));
render(col);
clearTimeout(wheelTimer);
wheelTimer = setTimeout(function () { settle(col, Math.round(col.pos), 260); }, 120);
}, { passive: false });
col.el.addEventListener('keydown', function (e) {
var big = Math.max(2, Math.round(col.n / 6)), cur = Math.round(col.tween ? col.target : col.pos);
var t = { ArrowUp: cur - 1, ArrowDown: cur + 1, PageUp: cur - big, PageDown: cur + big }[e.key];
if (e.key === 'Home') t = col.loop ? cur - index(col) : 0;
if (e.key === 'End') t = col.loop ? cur - index(col) + col.n - 1 : col.n - 1;
if (t === undefined) return;
e.preventDefault();
stop();
settle(col, t, 260);
});
});
window.addEventListener('resize', function () { measure(); cols.forEach(render); });
}
// Autoplay: spins one wheel at a time to a new value.
var stopped = false, timer = null, turn = 0;
function stop() { stopped = true; clearTimeout(timer); }
function run() {
if (stopped) return;
var col = cols[turn++ % cols.length], delta;
if (col.loop) delta = (Math.random() < 0.5 ? -1 : 1) * (3 + Math.floor(Math.random() * Math.min(14, col.n - 3)));
else delta = index(col) < col.n - 1 ? 1 + Math.floor(Math.random() * (col.n - 1 - index(col))) : -index(col);
settle(col, Math.round(col.pos) + delta, 700 + Math.abs(delta) * 70, 'outBack(1.1)');
timer = setTimeout(run, 1500);
}
if (on(d.autoplay) && !reduced()) {
new IntersectionObserver(function (entries) {
if (stopped) return;
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(run, 700);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-wheel-picker]').forEach(function (el) {
if (el._whp) return;
el._whp = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-wheel-picker data-value="7;30;AM" data-name="alarm" aria-label="Alarm time"></div>
<!-- or a date: -->
<div data-wheel-picker data-columns="1-31;Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec;2020-2030"
data-labels="Day;Month;Year" aria-label="Date"></div>