← Forms

Rotary Knob

A dial you turn by dragging round it, like the knob on an amplifier. A coloured arc fills round the outside as the value rises, the tick marks light up as it passes them and the number in the middle counts along. Turn it past the gap at the bottom and it simply stops at the end.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-rotary-knob and an aria-label: the dial is built inside it.
  3. Set the range with data-min and data-max, and size it with font-size: the dial is 10em across.

Options

Add any of these attributes to the same element.

data-minThe lowest value. Default 0.
data-maxThe highest value. Default 100.
data-stepThe step between values. Default 1.
data-valueThe starting value. Default 40.
data-suffixText after the number, such as %.
data-labelA small caption under the number.
data-colorArc colour. Default #6a0dad.
data-nameAdds a hidden input with this name, so the value posts with a form.
data-autoplayIt turns to new values by itself while in view, and stops as soon as you use it.

It’s a real slider (role="slider"): arrow keys, Page Up, Page Down, Home and End turn it, and so does the mouse wheel once it has focus. Dragging follows the change in angle round the centre, so it never jumps. Listen for the input event: e.detail.value is the new value.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Rotary Knob: a dial you turn by dragging round it. A coloured arc
    // fills round the outside to show the value, tick marks light up as it
    // passes them, and the number in the middle counts along.
    // Usage: <div data-rotary-knob aria-label="Volume"></div>
    //   Size it with font-size: the knob is 10em across.
    // Optional: data-min (default 0), data-max (default 100), data-step
    // (default 1), data-value (default 40), data-suffix (text after the
    // number, such as "%"), data-label (small caption under the number),
    // data-color (arc colour, default #6a0dad), data-name (adds a hidden input
    // with this name so the value posts with a form), data-autoplay (it turns
    // to new values by itself while in view; it stops as soon as you use it).
    // It's a real slider: arrow keys, Page Up/Down, Home, End and the mouse
    // wheel turn it. Listen for the "input" event: e.detail.value.
    var DEFAULTS = { min: 0, max: 100, step: 1, value: 40, color: '#6a0dad' };
    var SWEEP = 135; // degrees either side of straight up

    var css =
      '.rtk{position:relative;display:inline-block;width:10em;height:10em;outline:none;touch-action:none;user-select:none;-webkit-user-select:none;cursor:grab;-webkit-tap-highlight-color:transparent}' +
      '.rtk-drag{cursor:grabbing}' +
      '.rtk svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.rtk-track{fill:none;stroke:#e9e7ef;stroke-width:7;stroke-linecap:round}' +
      '.rtk-arc{fill:none;stroke:var(--rtk-color);stroke-width:7;stroke-linecap:round}' +
      '.rtk-tick{stroke:#d4d4d8;stroke-width:2;stroke-linecap:round;transition:stroke .2s}' +
      '.rtk-tick.rtk-lit{stroke:var(--rtk-color)}' +
      '.rtk-body{stroke:rgba(0,0,0,.06);stroke-width:1}' +
      '.rtk-dot{fill:var(--rtk-color)}' +
      '.rtk-read{position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);text-align:center;pointer-events:none;line-height:1.1}' +
      '.rtk-num{display:block;font-size:1.5em;font-weight:700;font-variant-numeric:tabular-nums;color:#27272a}' +
      '.rtk-cap{display:block;margin-top:.15em;font-size:.62em;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#8a8a93}' +
      '.rtk:focus-visible .rtk-body{stroke:var(--rtk-color);stroke-width:2}';
    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) { var n = parseFloat(v); return isNaN(n) ? f : n; }
    function pt(r, a) { var t = a * Math.PI / 180; return [(100 + r * Math.sin(t)).toFixed(2), (100 - r * Math.cos(t)).toFixed(2)]; }

    var count = 0;
    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 suffix = d.suffix || '';
      el.classList.add('rtk');
      el.style.setProperty('--rtk-color', d.color || DEFAULTS.color);
      el.textContent = '';

      // Each knob gets its own ids: they must be unique on the page.
      var id = 'rtk-' + (++count);
      var defs = '<defs><radialGradient id="' + id + '-grad" cx="40%" cy="30%" r="80%"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#ebe8f1"/></radialGradient>' +
        '<filter id="' + id + '-shadow" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="4" stdDeviation="5" flood-color="#1e1432" flood-opacity=".18"/></filter></defs>';
      var a0 = pt(88, -SWEEP), a1 = pt(88, SWEEP);
      var arc = 'M' + a0[0] + ' ' + a0[1] + ' A88 88 0 1 1 ' + a1[0] + ' ' + a1[1];
      var ticks = '';
      for (var i = 0; i <= 10; i++) {
        var a = -SWEEP + i * SWEEP * 2 / 10, p = pt(72, a), q = pt(76, a);
        ticks += '<line class="rtk-tick" x1="' + p[0] + '" y1="' + p[1] + '" x2="' + q[0] + '" y2="' + q[1] + '"/>';
      }
      el.innerHTML =
        '<svg viewBox="0 0 200 200" aria-hidden="true">' + defs +
        '<path class="rtk-track" d="' + arc + '"/>' +
        '<path class="rtk-arc" d="' + arc + '" pathLength="100" stroke-dasharray="0 100"/>' +
        ticks +
        '<circle class="rtk-body" cx="100" cy="100" r="60" fill="url(#' + id + '-grad)" filter="url(#' + id + '-shadow)"/>' +
        '<g class="rtk-knob"><circle class="rtk-dot" cx="100" cy="49" r="4.5"/></g>' +
        '</svg>' +
        '<span class="rtk-read" aria-hidden="true"><span class="rtk-num"></span>' + (d.label ? '<span class="rtk-cap"></span>' : '') + '</span>';
      if (d.label) el.querySelector('.rtk-cap').textContent = d.label;
      var arcEl = el.querySelector('.rtk-arc');
      var knob = el.querySelector('.rtk-knob');
      var numEl = el.querySelector('.rtk-num');
      var tickEls = el.querySelectorAll('.rtk-tick');

      if (!mock) {
        el.tabIndex = 0;
        el.setAttribute('role', 'slider');
        el.setAttribute('aria-valuemin', min);
        el.setAttribute('aria-valuemax', max);
        if (!el.getAttribute('aria-label') && d.label) el.setAttribute('aria-label', d.label);
      }
      var hidden = null;
      if (!mock && d.name) {
        hidden = document.createElement('input');
        hidden.type = 'hidden';
        hidden.name = d.name;
        el.appendChild(hidden);
      }

      // shown.f is the drawn position (0..1); it chases the value.
      var shown = { f: (value - min) / (max - min || 1) };
      function render() {
        var f = shown.f, ang = -SWEEP + f * SWEEP * 2;
        arcEl.setAttribute('stroke-dasharray', (f * 100).toFixed(2) + ' 100');
        knob.setAttribute('transform', 'rotate(' + ang.toFixed(2) + ' 100 100)');
        numEl.textContent = Math.round((min + f * (max - min)) / step) * step + suffix;
        tickEls.forEach(function (t, i) { t.classList.toggle('rtk-lit', i / 10 <= f + 0.001); });
      }
      var anim = null;
      function set(v, instant) {
        v = Math.max(min, Math.min(max, Math.round((v - min) / step) * step + min));
        v = parseFloat(v.toFixed(6));
        var changed = v !== value;
        value = v;
        if (!mock) { el.setAttribute('aria-valuenow', v); el.setAttribute('aria-valuetext', v + suffix); }
        if (hidden) hidden.value = v;
        var f = (v - min) / (max - min || 1);
        if (anim) anim.pause();
        if (instant || reduced()) { shown.f = f; render(); }
        else anim = anime.animate(shown, { f: f, duration: 520, ease: 'out(4)', onUpdate: render });
        if (changed && !mock) el.dispatchEvent(new CustomEvent('input', { detail: { value: v }, bubbles: true }));
      }
      set(value, true);

      // Dragging: follow the change in the pointer's angle round the centre,
      // so crossing the gap at the bottom doesn't jump from one end to the other.
      var dragAngle = null, dragF = 0;
      function angleOf(e) {
        var r = el.getBoundingClientRect();
        return Math.atan2(e.clientX - (r.left + r.width / 2), -(e.clientY - (r.top + r.height / 2))) * 180 / Math.PI;
      }
      if (!mock) {
        el.addEventListener('pointerdown', function (e) {
          if (e.button > 0) return;
          e.preventDefault();
          stop();
          el.focus({ preventScroll: true });
          el.setPointerCapture(e.pointerId);
          el.classList.add('rtk-drag');
          dragAngle = angleOf(e);
          dragF = (value - min) / (max - min || 1);
        });
        el.addEventListener('pointermove', function (e) {
          if (dragAngle === null) return;
          var a = angleOf(e), delta = a - dragAngle;
          if (delta > 180) delta -= 360;
          if (delta < -180) delta += 360;
          dragAngle = a;
          dragF = Math.max(0, Math.min(1, dragF + delta / (SWEEP * 2)));
          var v = min + dragF * (max - min);
          if (anim) anim.pause();
          anim = anime.animate(shown, { f: (Math.round((v - min) / step) * step) / (max - min || 1), duration: 120, ease: 'out(2)', onUpdate: render });
          var before = value;
          value = Math.max(min, Math.min(max, Math.round((v - min) / step) * step + min));
          value = parseFloat(value.toFixed(6));
          if (value !== before) {
            el.setAttribute('aria-valuenow', value);
            el.setAttribute('aria-valuetext', value + suffix);
            if (hidden) hidden.value = value;
            el.dispatchEvent(new CustomEvent('input', { detail: { value: value }, bubbles: true }));
          }
        });
        var endDrag = function () { dragAngle = null; el.classList.remove('rtk-drag'); };
        el.addEventListener('pointerup', endDrag);
        el.addEventListener('pointercancel', endDrag);
        el.addEventListener('keydown', function (e) {
          var big = Math.max(step, (max - min) / 10), k = e.key, v = null;
          if (k === 'ArrowUp' || k === 'ArrowRight') v = value + step;
          else if (k === 'ArrowDown' || k === 'ArrowLeft') v = value - step;
          else if (k === 'PageUp') v = value + big;
          else if (k === 'PageDown') v = value - big;
          else if (k === 'Home') v = min;
          else if (k === 'End') v = max;
          if (v === null) return;
          e.preventDefault();
          stop();
          set(v);
        });
        el.addEventListener('wheel', function (e) {
          if (document.activeElement !== el) return;
          e.preventDefault();
          stop();
          set(value + (e.deltaY < 0 ? step : -step) * Math.max(1, Math.round((max - min) / 100 / step)));
        }, { passive: false });
      }

      // Autoplay: turn to a new value every so often.
      var timer = 0, stopped = false;
      function stop() { stopped = true; clearTimeout(timer); }
      var seq = [0.82, 0.25, 0.6, 1, 0.1, 0.45], si = 0;
      function next() {
        if (stopped) return;
        set(min + seq[si++ % seq.length] * (max - min));
        timer = setTimeout(next, 1500);
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (!stopped && entries[0].isIntersecting) timer = setTimeout(next, 700);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-rotary-knob]').forEach(function (el) {
        if (el._rtk) return;
        el._rtk = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-rotary-knob data-value="40" data-suffix="%" data-label="Volume" aria-label="Volume"></div>