← Forms

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

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-wheel-picker and an aria-label: the wheels are built inside it.
  3. List the wheels in data-columns (it’s a time picker if you leave it out), and size it with font-size.

Options

Add any of these attributes to the same element.

data-columnsThe 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-valueStarting values in the same order, such as 7;30;AM.
data-labelsA name for each wheel, for screen readers. Default Hour;Minute;AM or PM.
data-nameAdds a hidden input with this name holding the values joined by spaces, so they post with a form.
data-autoplayThe 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.

1 · Snippet
<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>
2 · Your HTML
<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>