Color Picker
Pick a colour, copy HEX/RGB/HSL, and see its harmonies
Running live from https://fun.whitneys.co,
cross-origin and sandboxed.
Put it on your site
<iframe
src="https://fun.whitneys.co/g/color-picker"
width="340"
height="500"
title="Color Picker"
sandbox="allow-scripts allow-popups-to-escape-sandbox"
allow="clipboard-write"
loading="lazy"
style="border:0"></iframe>
An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.
Optional: let it resize itself to fit
<script>
addEventListener('message', function (e) {
var d = e.data;
if (!d || d.type !== 'fwf:height') return;
if (typeof d.height !== 'number' || !isFinite(d.height)) return;
document.querySelectorAll('iframe').forEach(function (f) {
// Identity by source window, not by origin -- sandboxed frames report
// their origin as "null". Do not "fix" this into an origin check.
if (f.contentWindow !== e.source) return;
f.style.height = Math.max(40, Math.min(2000, Math.ceil(d.height))) + 'px';
});
});
</script>
The whole thing
Raw fileEvery gadget is one file, and this is it -- all 15.6 kB. Take it, change it, ship it. Just keep the credit.
Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
"name": "Color Picker",
"tagline": "Pick a colour, copy HEX/RGB/HSL, and see its harmonies",
"category": "tools",
"tags": ["color", "picker", "design", "hex"],
"order": 3,
"author": "rwhitney",
"background": "dark",
"size": { "w": 340, "h": 500, "resizable": true },
"config": {
"start": { "type": "color", "label": "Starting colour", "default": "#66D9EF" },
"accent": { "type": "color", "label": "UI accent", "default": "#A6E22E" },
"alpha": { "type": "bool", "label": "Alpha channel", "default": false },
"harmony": { "type": "bool", "label": "Show harmonies", "default": true }
},
"permissions": ["clipboard"]
}
</script>
<style>
:root { --ink:#F8F8F2; --dim:#A59F85; --line:rgb(248 248 242 / .14);
--panel:rgb(248 248 242 / .05); }
html, body {
margin:0; background:transparent; color:var(--ink);
font:14px/1.4 ui-sans-serif, system-ui, sans-serif;
user-select:none; -webkit-user-select:none;
}
#pick { padding:14px; box-sizing:border-box; }
/* ---- saturation / value square -------------------------------------- */
#sv {
position:relative; width:100%; height:150px; border-radius:10px;
cursor:crosshair; touch-action:none;
background:
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, transparent),
hsl(var(--h, 197) 100% 50%);
border:1px solid var(--line);
}
#sv:focus { outline:none; }
#sv:focus-visible { outline:2px solid var(--accent, #A6E22E); outline-offset:2px; }
#svdot {
position:absolute; width:16px; height:16px; border-radius:50%;
border:2px solid #fff; box-shadow:0 0 0 1px rgb(0 0 0 / .5), 0 1px 3px rgb(0 0 0 / .5);
transform:translate(-50%, -50%); pointer-events:none;
}
/* ---- sliders --------------------------------------------------------- */
.slider { -webkit-appearance:none; appearance:none; width:100%; height:14px;
border-radius:8px; margin:12px 0 0; cursor:pointer; border:1px solid var(--line); }
.slider:focus { outline:none; }
.slider:focus-visible { outline:2px solid var(--accent, #A6E22E); outline-offset:2px; }
#hue { background:linear-gradient(to right,
#f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); }
#alpha { display:none; }
#alpha.on { display:block; }
.slider::-webkit-slider-thumb {
-webkit-appearance:none; width:16px; height:16px; border-radius:50%;
background:#fff; border:2px solid rgb(0 0 0 / .4); box-shadow:0 1px 3px rgb(0 0 0 / .5);
}
.slider::-moz-range-thumb {
width:14px; height:14px; border-radius:50%;
background:#fff; border:2px solid rgb(0 0 0 / .4);
}
/* ---- preview + outputs ---------------------------------------------- */
#preview {
height:46px; border-radius:9px; margin-top:14px;
display:flex; align-items:center; justify-content:center;
font:700 15px/1 ui-monospace, Menlo, Consolas, monospace; letter-spacing:.04em;
border:1px solid var(--line);
/* checkerboard shows through under transparency */
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size:14px 14px;
background-position:0 0, 0 7px, 7px -7px, -7px 0;
}
#preview span { padding:2px 8px; border-radius:5px; }
.out { margin-top:8px; display:flex; flex-direction:column; gap:6px; }
.row {
display:flex; align-items:center; gap:10px; width:100%;
background:var(--panel); border:1px solid var(--line); border-radius:8px;
padding:8px 10px; font:inherit; color:var(--ink); cursor:pointer; text-align:left;
}
.row:hover { border-color:var(--accent, #A6E22E); }
.row .tag { font-size:10px; font-weight:700; letter-spacing:.08em; color:var(--dim);
width:30px; }
.row .val { flex:1; font-family:ui-monospace, Menlo, Consolas, monospace;
font-size:13px; }
.row .cp { font-size:11px; color:var(--dim); }
.row.copied .cp { color:var(--accent, #A6E22E); }
.row:focus { outline:none; }
.row:focus-visible { outline:2px solid var(--accent, #A6E22E); outline-offset:1px; }
/* ---- harmonies + tools ---------------------------------------------- */
h3 { font-size:10px; letter-spacing:.14em; text-transform:uppercase;
color:var(--dim); margin:16px 0 7px; font-weight:700; }
#harm { display:flex; gap:6px; }
#harm button {
flex:1; height:34px; border-radius:7px; border:1px solid var(--line);
cursor:pointer; padding:0;
}
#harm button:hover { transform:translateY(-1px); }
#harm button:focus-visible { outline:2px solid #fff; outline-offset:1px; }
#tools { display:flex; gap:8px; margin-top:12px; align-items:center; }
#hexin {
flex:1; min-width:0; background:rgb(0 0 0 / .25); color:var(--ink);
border:1px solid var(--line); border-radius:7px; padding:7px 10px;
font:inherit; font-family:ui-monospace, Menlo, monospace; font-size:13px;
text-transform:uppercase;
}
#hexin:focus { outline:none; border-color:var(--accent, #A6E22E); }
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
</style>
</head>
<body>
<div id="pick">
<div id="sv" tabindex="0" role="slider" aria-label="Saturation and brightness"
aria-valuetext="">
<div id="svdot"></div>
</div>
<input id="hue" class="slider" type="range" min="0" max="360" step="1" aria-label="Hue">
<input id="alpha" class="slider" type="range" min="0" max="100" step="1" value="100" aria-label="Alpha">
<div id="preview"><span id="pvtext">#66D9EF</span></div>
<div class="out">
<button class="row" data-fmt="hex"><span class="tag">HEX</span><span class="val" id="oHex"></span><span class="cp">copy</span></button>
<button class="row" data-fmt="rgb"><span class="tag">RGB</span><span class="val" id="oRgb"></span><span class="cp">copy</span></button>
<button class="row" data-fmt="hsl"><span class="tag">HSL</span><span class="val" id="oHsl"></span><span class="cp">copy</span></button>
</div>
<div id="harmwrap">
<h3>Harmonies -- tap to adopt</h3>
<div id="harm"></div>
</div>
<div id="tools">
<input id="hexin" type="text" spellcheck="false" maxlength="9"
aria-label="Type a hex colour" placeholder="Type a hex, e.g. #66D9EF">
</div>
<div class="sr" role="status" aria-live="polite" id="say"></div>
</div>
<script>
(function () {
var cfg = window.gadget ? window.gadget.config : {};
var useAlpha = cfg.alpha === true;
var showHarm = cfg.harmony !== false;
document.documentElement.style.setProperty('--accent', cfg.accent || '#A6E22E');
/* ---- colour math (verified in Node before shipping) ------------------ */
function hsvToRgb(h, s, v) {
h = ((h % 360) + 360) % 360;
var c = v * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = v - c, r, g, b;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120){ r = x; g = c; b = 0; }
else if (h < 180){ r = 0; g = c; b = x; }
else if (h < 240){ r = 0; g = x; b = c; }
else if (h < 300){ r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
function rgbToHsv(r, g, b) {
r /= 255; g /= 255; b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
var h = 0, s = max === 0 ? 0 : d / max, v = max;
if (d) {
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0));
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return [h, s, v];
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
var l = (max + min) / 2, h = 0, s = 0;
if (d) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0));
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return [Math.round(h), Math.round(s * 100), Math.round(l * 100)];
}
function hex2(n) { return (n | 0).toString(16).padStart(2, '0'); }
function rgbToHex(r, g, b) { return '#' + hex2(r) + hex2(g) + hex2(b); }
function hexToRgb(hex) {
var s = String(hex).trim().replace(/^#/, '');
// Accept #rgb, #rrggbb, and #rrggbbaa (8-digit with alpha) so the hex box
// tolerates any common form a designer might paste.
// perfectly good picked colour was thrown away.
if (/^[0-9a-f]{3}$/i.test(s)) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
else if (/^[0-9a-f]{8}$/i.test(s)) s = s.slice(0, 6);
if (!/^[0-9a-f]{6}$/i.test(s)) return null;
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function relLum(r, g, b) { // for picking readable preview text
var a = [r, g, b].map(function (v) {
v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
}
/* ---- state ----------------------------------------------------------- */
var H = 197, S = 0.57, V = 0.94, A = 1; // start; overwritten from config below
(function initFromCfg() {
var rgb = hexToRgb(cfg.start || '#66D9EF') || [102, 217, 239];
var hsv = rgbToHsv(rgb[0], rgb[1], rgb[2]);
H = hsv[0]; S = hsv[1]; V = hsv[2];
})();
var sv = document.getElementById('sv'), dot = document.getElementById('svdot');
var hue = document.getElementById('hue'), alpha = document.getElementById('alpha');
var preview = document.getElementById('preview'), pvtext = document.getElementById('pvtext');
var harm = document.getElementById('harm');
if (useAlpha) alpha.classList.add('on');
if (!showHarm) document.getElementById('harmwrap').style.display = 'none';
// Checkerboard layers, applied under the colour so alpha is visible.
var CHECK = 'linear-gradient(45deg,#555 25%,transparent 25%),' +
'linear-gradient(-45deg,#555 25%,transparent 25%),' +
'linear-gradient(45deg,transparent 75%,#555 75%),' +
'linear-gradient(-45deg,transparent 75%,#555 75%)';
var CHECK_SIZE = '14px 14px,14px 14px,14px 14px,14px 14px';
var CHECK_POS = '0 0,0 7px,7px -7px,-7px 0';
function currentRgb() { return hsvToRgb(H, S, V); }
function render() {
var rgb = currentRgb();
var hex = rgbToHex(rgb[0], rgb[1], rgb[2]);
var hsl = rgbToHsl(rgb[0], rgb[1], rgb[2]);
var css = 'rgb(' + rgb.join(', ') + ')';
sv.style.setProperty('--h', H);
dot.style.left = (S * 100) + '%';
dot.style.top = ((1 - V) * 100) + '%';
dot.style.background = hex;
hue.value = Math.round(H);
hue.style.setProperty('--h', H);
// outputs (respect alpha when enabled)
var aOut = useAlpha ? A : 1;
if (useAlpha && aOut < 1) {
document.getElementById('oHex').textContent = hex + hex2(Math.round(aOut * 255));
document.getElementById('oRgb').textContent = 'rgba(' + rgb.join(', ') + ', ' + round2(aOut) + ')';
document.getElementById('oHsl').textContent = 'hsla(' + hsl[0] + ', ' + hsl[1] + '%, ' + hsl[2] + '%, ' + round2(aOut) + ')';
css = 'rgba(' + rgb.join(', ') + ', ' + aOut + ')';
} else {
document.getElementById('oHex').textContent = hex;
document.getElementById('oRgb').textContent = 'rgb(' + rgb.join(', ') + ')';
document.getElementById('oHsl').textContent = 'hsl(' + hsl[0] + ', ' + hsl[1] + '%, ' + hsl[2] + '%)';
}
// The colour must sit ON TOP of the checkerboard, not behind it -- a
// background-color paints under the background-image, so the checker grays
// were showing over an opaque swatch. Layer the colour as its own opaque
// gradient above the checker; only alpha < 1 lets the checker through.
preview.style.backgroundImage = 'linear-gradient(' + css + ',' + css + '),' + CHECK;
preview.style.backgroundSize = '100% 100%,' + CHECK_SIZE;
preview.style.backgroundPosition = '0 0,' + CHECK_POS;
pvtext.textContent = hex.toUpperCase();
var readable = relLum(rgb[0], rgb[1], rgb[2]) > 0.35 ? '#111' : '#fff';
pvtext.style.color = readable;
sv.setAttribute('aria-valuetext', hex);
if (showHarm) renderHarmonies();
document.getElementById('say').textContent = hex;
}
function round2(x) { return Math.round(x * 100) / 100; }
/* Harmonies: keep S and V, rotate the hue. */
var HARMONIES = [
{ d: -30, label: 'analogous -30' }, { d: 30, label: 'analogous +30' },
{ d: 120, label: 'triad +120' }, { d: 240, label: 'triad +240' },
{ d: 180, label: 'complement' }
];
function renderHarmonies() {
harm.innerHTML = '';
HARMONIES.forEach(function (spec) {
var rgb = hsvToRgb(H + spec.d, S, V);
var hex = rgbToHex(rgb[0], rgb[1], rgb[2]);
var b = document.createElement('button');
b.type = 'button';
b.style.background = hex;
b.title = spec.label + ' ' + hex;
b.setAttribute('aria-label', spec.label + ', ' + hex);
b.addEventListener('click', function () { H = ((H + spec.d) % 360 + 360) % 360; render(); });
harm.appendChild(b);
});
}
/* ---- saturation/value square: pointer + keyboard --------------------- */
var dragging = false;
function svAt(e) {
var r = sv.getBoundingClientRect();
S = clamp((e.clientX - r.left) / r.width, 0, 1);
V = clamp(1 - (e.clientY - r.top) / r.height, 0, 1);
render();
}
function clamp(x, a, b) { return x < a ? a : x > b ? b : x; }
sv.addEventListener('pointerdown', function (e) {
dragging = true; sv.setPointerCapture(e.pointerId); e.preventDefault(); svAt(e); sv.focus();
});
sv.addEventListener('pointermove', function (e) { if (dragging) svAt(e); });
sv.addEventListener('pointerup', function (e) { dragging = false; sv.releasePointerCapture(e.pointerId); });
sv.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 0.1 : 0.02, used = true;
if (e.key === 'ArrowLeft') S = clamp(S - step, 0, 1);
else if (e.key === 'ArrowRight') S = clamp(S + step, 0, 1);
else if (e.key === 'ArrowUp') V = clamp(V + step, 0, 1);
else if (e.key === 'ArrowDown') V = clamp(V - step, 0, 1);
else used = false;
if (used) { e.preventDefault(); render(); }
});
hue.addEventListener('input', function () { H = parseFloat(hue.value); render(); });
alpha.addEventListener('input', function () { A = parseFloat(alpha.value) / 100; render(); });
/* ---- copy ------------------------------------------------------------ */
[].forEach.call(document.querySelectorAll('.row'), function (row) {
row.addEventListener('click', function () {
var txt = row.querySelector('.val').textContent;
var done = function () {
row.classList.add('copied');
row.querySelector('.cp').textContent = 'copied';
setTimeout(function () {
row.classList.remove('copied');
row.querySelector('.cp').textContent = 'copy';
}, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(done, function () {});
}
});
});
/* ---- hex input ------------------------------------------------------- */
var hexin = document.getElementById('hexin');
function adoptHex(hex) {
var rgb = hexToRgb(hex);
if (!rgb) return false;
var hsv = rgbToHsv(rgb[0], rgb[1], rgb[2]);
H = hsv[0]; S = hsv[1]; V = hsv[2];
render();
return true;
}
hexin.addEventListener('input', function () {
var v = hexin.value.trim();
if (/^#?[0-9a-f]{6}$/i.test(v)) adoptHex(v);
});
hexin.addEventListener('blur', function () { hexin.value = ''; });
render();
})();
</script>
</body>
</html>