Every design tool has the same control: a big square you drag around, a rainbow strip beside it, and a hex box at the bottom. It feels like it must be a browser primitive. It is not <input type="color"> either, because that just opens the OS picker and gives you back a hex string with zero control over how it looks or behaves. For Day 28 of my DesignFromZero series I built the whole thing by hand, and it turns out to be mostly color math plus one small drag helper.
Three ways to name a color
A screen mixes red, green and blue light, so RGB (three 0–255 channels) is how the machine stores color. It is terrible to steer by hand: to get "a slightly less vivid blue" you have to nudge three numbers at once and hope. HSL and HSV are the same space remapped onto axes people actually think in — Hue (which color, 0–360°), then either Saturation/Lightness or Saturation/Value.
Pickers use HSV specifically, because it fits a 2-D pad plus a 1-D rail perfectly. Fix the hue on the rail, and the square's two remaining axes fill out cleanly: saturation left-to-right, value top-to-bottom. Top-left is white, top-right is the pure vivid hue, the whole bottom edge is black. That matches how you reason — "give me a blue" (spin the rail), "a muted dark one" (drag toward the bottom-left). HSL's square can't put a pure vivid color in a corner, so it feels wrong for this.
The square is two gradients
You do not need a canvas. The square's base background-color is the pure hue, and two stacked CSS gradients do the rest:
.sv {
background-color: hsl(0,100%,50%); /* JS updates the hue */
background-image:
linear-gradient(to top, #000, transparent), /* value -> black */
linear-gradient(to right, #fff, transparent); /* satur. -> white */
}
The thumb sits at left: sat*100% and top: (1 - val)*100%. The hue rail is a single seven-stop rainbow so top and bottom are both red (0° = 360°). The alpha rail is a checkerboard with a color-to-transparent gradient painted over it, so you can actually see the transparency.
HSV to RGB is six lines
To paint anything you convert HSV back to RGB. Chroma c = v*s is the colorfulness; the hue splits into six 60° sectors; x is the second-strongest channel rising and falling across each sector; and m = v - c lifts everything off black:
function hsvToRgb(h, s, v){
const c = v*s, x = c*(1 - Math.abs((h/60)%2 - 1)), m = v - c;
const seg = [[c,x,0],[x,c,0],[0,c,x],[0,x,c],[x,0,c],[c,0,x]][Math.floor(h/60)%6];
return { r: Math.round((seg[0]+m)*255),
g: Math.round((seg[1]+m)*255),
b: Math.round((seg[2]+m)*255) };
}
Sanity check against a color you know: hsvToRgb(0, 1, 1) gives {255, 0, 0}, which rgbToHex turns into #ff0000, which rgbToHsl reports as hsl(0, 100%, 50%). That last one is the classic gotcha — vivid red is 100% value in HSV but 50% lightness in HSL, because HSL reserves 100% lightness for white.
One drag helper for all three controls
The core interaction is turning a cursor position into two numbers. setPointerCapture keeps the drag alive when the cursor flies out of the box:
function drag(el, on){
el.addEventListener("pointerdown", e => {
el.setPointerCapture(e.pointerId);
const move = ev => { const r = el.getBoundingClientRect();
on(clamp((ev.clientX - r.left)/r.width, 0, 1),
clamp((ev.clientY - r.top)/r.height, 0, 1)); };
move(e); el.onpointermove = move;
el.onpointerup = () => el.onpointermove = null;
});
}
drag(sv, (x,y) => { sat = x; val = 1 - y; render(); });
drag(hue, (x,y) => { hue = y * 360; render(); });
Both rails reuse the same helper and just ignore the axis they don't care about.
Make it two-way
A picker that only outputs text is half a tool. Make each output an editable input, and on every keystroke parse it back into the state — but only if it is valid, because #1 is a legitimate mid-typing keystroke. And inside render(), never overwrite the field the user is currently focused in, or you fight their cursor:
const set = (el, v) => { if (document.activeElement !== el) el.value = v; };
Now typing #10b981 snaps the square, both rails and the preview into place.
The accessibility bits people skip
Around 8% of men have some color-vision deficiency, so a chosen color must never be the only signal — pair it with text or icons. If the color is used for text, check contrast: WCAG's ratio wants ≥ 4.5:1 for AA, and it is a few lines of relative-luminance math to show live. And give the square and rails focus rings plus arrow-key support so it is not mouse-only.
One genuine platform primitive is worth using: in Chromium you can feature-detect window.EyeDropper and sample any pixel on screen. Firefox and Safari don't have it yet, so disable the button rather than call an undefined constructor.
Live demo with a LOOK / UNDERSTAND / BUILD walkthrough: https://dev48v.infy.uk/design/day28-color-picker.html
Top comments (0)