← All gadgets

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 file

Every 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>