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
- Paste the snippet just before
</head>. - Add an empty element with
data-colour-wheel-pickerand anaria-label: the wheel is built inside it. - Size it with
font-size: the wheel is 10em across.
Options
Add any of these attributes to the same element.
data-hue | The starting hue, from 0 to 360. Default 270. |
data-saturation | Saturation of every colour on the wheel, in %. Default 80. |
data-lightness | Lightness of every colour on the wheel, in %. 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 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.
<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><div data-colour-wheel-picker data-name="colour" aria-label="Theme colour"></div>