DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Build a color picker from scratch (no native input type=color)

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 */
}
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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(); });
Enter fullscreen mode Exit fullscreen mode

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; };
Enter fullscreen mode Exit fullscreen mode

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)