← Forms

Colour Wheel Picker

Drag the handle round a rainbow ring to pick a hue. The handle takes on the colour under it, and the swatch in the middle follows a beat behind, easing into the new colour with its hex code on it. Let go and the swatch gives a little pop. Tap anywhere on the ring and the handle glides there the short way round.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-colour-wheel-picker and an aria-label: the wheel is built inside it.
  3. Size it with font-size: the wheel is 10em across.

Options

Add any of these attributes to the same element.

data-hueThe starting hue, from 0 to 360. Default 270.
data-saturationSaturation of every colour on the wheel, in %. Default 80.
data-lightnessLightness of every colour on the wheel, in %. Default 55.
data-nameAdds a hidden input with this name holding the hex colour, so it posts with a form.
data-autoplayThe handle travels round to new colours by itself while in view. It stops as soon as you use it.

It’s a real slider (role="slider"): arrow keys move it 5° at a time and Page Up and Page Down 30°, and screen readers hear the colour’s name and hex code. Listen for the input event: e.detail.hex is the colour and e.detail.hue the hue.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Colour Wheel Picker: drag the handle round a rainbow ring to pick a hue.
    // The swatch in the middle follows a beat behind, easing into the new
    // colour with its hex code on it, and gives a little pop when you let go.
    // Usage: <div data-colour-wheel-picker aria-label="Theme colour"></div>
    //   Size it with font-size: the wheel is 10em across.
    // Optional: data-hue (starting hue, 0 to 360, default 270), data-saturation
    // (%, default 80), data-lightness (%, default 55), data-name (adds a hidden
    // input with this name holding the hex colour, so it posts with a form),
    // data-autoplay (the handle travels round by itself while in view; it
    // stops as soon as you use it).
    // It's a real slider: arrow keys, Page Up/Down, Home and End move it.
    // Listen for the "input" event: e.detail.hex and e.detail.hue.
    var DEFAULTS = { hue: 270, saturation: 80, lightness: 55 };

    var css =
      '.cwp{position:relative;display:inline-block;width:10em;height:10em;border-radius:50%;outline:none;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}' +
      '.cwp-ring{position:absolute;inset:0;border-radius:50%;cursor:pointer;' +
      'background:conic-gradient(hsl(0,var(--cwp-s),var(--cwp-l)),hsl(60,var(--cwp-s),var(--cwp-l)),hsl(120,var(--cwp-s),var(--cwp-l)),hsl(180,var(--cwp-s),var(--cwp-l)),hsl(240,var(--cwp-s),var(--cwp-l)),hsl(300,var(--cwp-s),var(--cwp-l)),hsl(360,var(--cwp-s),var(--cwp-l)));' +
      '-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 1.5em),#000 calc(100% - 1.5em + .5px));mask:radial-gradient(farthest-side,transparent calc(100% - 1.5em),#000 calc(100% - 1.5em + .5px))}' +
      '.cwp-swatch{position:absolute;left:50%;top:50%;width:5.4em;height:5.4em;margin:-2.7em 0 0 -2.7em;border-radius:50%;display:grid;place-items:center;' +
      'box-shadow:0 0 0 .3em #fff,0 .5em 1.4em rgba(20,20,40,.18);pointer-events:none}' +
      '.cwp-hex{font-size:.82em;font-weight:700;letter-spacing:.04em;font-variant-numeric:tabular-nums;transition:color .3s}' +
      '.cwp-handle{position:absolute;left:50%;top:50%;width:1.9em;height:1.9em;margin:-.95em 0 0 -.95em;border-radius:50%;box-sizing:border-box;border:.25em solid #fff;' +
      'box-shadow:0 .15em .5em rgba(20,20,40,.3);pointer-events:none;transition:width .2s,height .2s,margin .2s}' +
      '.cwp-drag .cwp-handle{width:2.4em;height:2.4em;margin:-1.2em 0 0 -1.2em}' +
      '.cwp:focus-visible .cwp-handle{outline:2px solid #18181b;outline-offset:2px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NAMES = [[15, 'red'], [40, 'orange'], [65, 'yellow'], [90, 'lime'], [150, 'green'], [175, 'teal'], [200, 'cyan'], [240, 'blue'], [265, 'indigo'], [290, 'purple'], [320, 'magenta'], [345, 'pink'], [361, 'red']];

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function wrap(h) { return ((h % 360) + 360) % 360; }
    function hex(h, s, l) {
      s /= 100; l /= 100;
      var a = s * Math.min(l, 1 - l);
      function f(n) {
        var k = (n + h / 30) % 12;
        var c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
        return ('0' + Math.round(c * 255).toString(16)).slice(-2);
      }
      return '#' + f(0) + f(8) + f(4);
    }
    function light(hx) {
      var r = parseInt(hx.slice(1, 3), 16), g = parseInt(hx.slice(3, 5), 16), b = parseInt(hx.slice(5, 7), 16);
      return 0.299 * r + 0.587 * g + 0.114 * b > 165;
    }
    function name(h) { for (var i = 0; i < NAMES.length; i++) if (h < NAMES[i][0]) return NAMES[i][1]; return 'red'; }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var sat = parseFloat(d.saturation) || DEFAULTS.saturation;
      var lig = parseFloat(d.lightness) || DEFAULTS.lightness;
      var hue = wrap(isNaN(parseFloat(d.hue)) ? DEFAULTS.hue : parseFloat(d.hue));
      el.classList.add('cwp');
      el.style.setProperty('--cwp-s', sat + '%');
      el.style.setProperty('--cwp-l', lig + '%');
      el.textContent = '';
      el.innerHTML = '<span class="cwp-ring"></span><span class="cwp-swatch"><span class="cwp-hex"></span></span><span class="cwp-handle"></span>';
      el.querySelectorAll('span').forEach(function (s) { if (s.parentNode === el) s.setAttribute('aria-hidden', 'true'); });
      var swatch = el.querySelector('.cwp-swatch');
      var hexEl = el.querySelector('.cwp-hex');
      var handle = el.querySelector('.cwp-handle');

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

      // st.h is the drawn hue, unwrapped so it can travel the short way round.
      var st = { h: hue }, swatchAnim = null, lastHex = '';
      function render() {
        var h = wrap(st.h), c = hex(h, sat, lig);
        handle.style.transform = 'rotate(' + h.toFixed(2) + 'deg) translateY(-4.25em) rotate(' + (-h).toFixed(2) + 'deg)';
        handle.style.background = c;
        if (c === lastHex) return;
        lastHex = c;
        // The swatch eases after the handle rather than snapping.
        if (swatchAnim) swatchAnim.pause();
        if (reduced()) swatch.style.background = c;
        else swatchAnim = anime.animate(swatch, { backgroundColor: c, duration: 420, ease: 'out(3)' });
        hexEl.textContent = c.toUpperCase();
        hexEl.style.color = light(c) ? 'rgba(0,0,0,.72)' : '#fff';
      }
      swatch.style.background = hex(hue, sat, lig);
      render();

      var anim = null;
      function set(h, how) {
        var target = wrap(h);
        hue = Math.round(target) % 360;
        var c = hex(hue, sat, lig);
        if (!mock) {
          el.setAttribute('aria-valuenow', hue);
          el.setAttribute('aria-valuetext', name(hue) + ', ' + c.toUpperCase());
          el.dispatchEvent(new CustomEvent('input', { detail: { hue: hue, hex: c }, bubbles: true }));
        }
        if (hidden) hidden.value = c;
        // Go the short way round.
        var cur = st.h, delta = wrap(target - wrap(cur));
        if (delta > 180) delta -= 360;
        if (anim) anim.pause();
        if (how === 'instant' || reduced()) { st.h = cur + delta; render(); return; }
        anim = anime.animate(st, { h: cur + delta, duration: how === 'drag' ? 90 : how === 'auto' ? 1300 : 480, ease: how === 'auto' ? 'inOut(3)' : 'out(3)', onUpdate: render });
      }
      set(hue, 'instant');

      function pop() {
        if (reduced()) return;
        anime.animate(swatch, { scale: [0.9, 1], duration: 600, ease: 'outElastic(1, .5)' });
      }

      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;
      }
      var dragging = false;
      if (!mock) {
        el.addEventListener('pointerdown', function (e) {
          if (e.button > 0) return;
          e.preventDefault();
          stop();
          el.focus({ preventScroll: true });
          el.setPointerCapture(e.pointerId);
          dragging = true;
          el.classList.add('cwp-drag');
          set(angleOf(e));
        });
        el.addEventListener('pointermove', function (e) { if (dragging) set(angleOf(e), 'drag'); });
        var end = function () { if (!dragging) return; dragging = false; el.classList.remove('cwp-drag'); pop(); };
        el.addEventListener('pointerup', end);
        el.addEventListener('pointercancel', end);
        el.addEventListener('keydown', function (e) {
          var k = e.key, h = null;
          if (k === 'ArrowRight' || k === 'ArrowUp') h = hue + 5;
          else if (k === 'ArrowLeft' || k === 'ArrowDown') h = hue - 5;
          else if (k === 'PageUp') h = hue + 30;
          else if (k === 'PageDown') h = hue - 30;
          else if (k === 'Home') h = 0;
          else if (k === 'End') h = 359;
          if (h === null) return;
          e.preventDefault();
          stop();
          set(h);
        });
      }

      // Autoplay: the handle glides to new hues round the wheel.
      var timer = 0, stopped = false, seq = [200, 20, 140, 320, 50, 250], si = 0;
      function stop() { stopped = true; clearTimeout(timer); el.classList.remove('cwp-drag'); }
      function next() {
        if (stopped) return;
        el.classList.add('cwp-drag');
        set(seq[si++ % seq.length], 'auto');
        timer = setTimeout(function () {
          if (stopped) return;
          el.classList.remove('cwp-drag');
          pop();
          timer = setTimeout(next, 900);
        }, 1300);
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          el.classList.remove('cwp-drag');
          if (!stopped && entries[0].isIntersecting) timer = setTimeout(next, 600);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-colour-wheel-picker]').forEach(function (el) {
        if (el._cwp) return;
        el._cwp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-colour-wheel-picker data-name="colour" aria-label="Theme colour"></div>