GODRICH

Custom Color Picker: 9 JS Parts, No Library

A custom color picker is a control for choosing a color on screen and getting a HEX code back.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are ordered by how color works inside a picker, not by how they look. First the two axes that pick a color by position (01, 02), then notations converting in and out (03), then transparency (04), then picked colors piling up (05), then one hue fanning out into palettes (06), then the measurement of two colors meeting (07), then color stretched into a gradient (08), and only at the end lifting a color straight off the screen (09).

01Saturation and brightness square you drag

The base axis of color picking. Side to side reads as saturation, up and down as brightness, and as you drag, the HSV under the knob is converted to RGB that flips the chip and the HEX text in the same instant. Its accumulated changed area is 2.738% of the canvas — the quietest of the nine.

setPointerCapturecounter-set@property
// Three registered properties are the only source of the color — chip fill, HEX text, all of it
@property --r { syntax: "<integer>"; inherits: true; initial-value: 204; }
@property --g { syntax: "<integer>"; inherits: true; initial-value: 228; }
@property --b { syntax: "<integer>"; inherits: true; initial-value: 255; }
@counter-style hx {
  system: numeric;
  symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
  pad: 2 "0";   // pads the front digit, as in 0F
}
.pk__hex { counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b)); }
.pk__hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
// HSV (0~360, 0~1, 0~1) → RGB (0~255). Six segments laid out as a table, no branching
function hsvToRgb(h, s, v) {
  var c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
  var face = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(h / 60) % 6];
  return face.map(function (n) { return Math.round((n + m) * 255); });
}
// Dragging past the edge keeps feeding this element — the one line that stops the drag from dropping
el.setPointerCapture(e.pointerId);

02Hue wheel that reads the angle you press

When only hue matters, a circle beats a square. The angle of the point you press becomes a hue from 0 to 360, and the knob rides the rim right on top of its own color — --h is the only value that moves, so the core fill and the degree readout cannot disagree.

conic-gradientMath.atan2rotate
// Measure the angle from the center. Math.atan2 puts 0 at 3 o'clock growing clockwise,
// and conic-gradient(from 90deg) also puts red (hue 0) at 3 o'clock — same reference, so the
// angle IS the hue with no correction
var deg = Math.atan2(e.clientY - box.top - box.height / 2,
                     e.clientX - box.left - box.width / 2) * 180 / Math.PI;
hue = Math.round((deg + 360) % 360) % 360;
// The knob rides the ring — its rotation center is the ring's center, not its own, so the
// ring-sized layer turns as one. +90 corrects for the conic's from 90deg
.hw__arm { transform: rotate(calc((var(--h) + 90) * 1deg)); }
.hw__arm::after {
  transform: translate(-50%, -50%) translateY(-51px);   // mid-ring (radius 51px)
}
.hw__core { background: hsl(var(--h) 92% 52%); }

03HEX, RGB and HSL boxes that follow each other

One color, three notations. Type into one box and the other two are recomputed to the same color; only a malformed box stays red with aria-invalid. A devtools-style input for editing design tokens — accumulated area 6.699%, frames moved 20/23.

@counter-stylearia-invalidcounter-set
// Preview text — six properties hold the color, counters split it into three notations.
// Loop basis: in the l <= 0.5 range the RGB of hsl(212, 90%, l) is (0.1l, 0.94l, 1.9l) x 255,
// fully proportional to lightness — hue 212 and saturation 90% stay pinned, so both notations
// point at the same color in every frame
.sy.is-demo .sy__input { color: transparent; }   // real input hidden, space kept
.sy.is-demo .sy__ghost { opacity: 1; }
.sy__ghost--hex::after  { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
.sy__ghost--rgb::after  { content: "rgb(" counter(cr) ", " counter(cg) ", " counter(cb) ")"; }
.sy__ghost--hsl::after  { content: "hsl(" counter(chh) ", " counter(css) "%, " counter(cll) "%)"; }

04Alpha slider sliding over a checkerboard

A transparent color is invisible on white, so the alpha slider starts by laying a checkerboard. --a is the opacity of the color overlay on top — pull it down and the checkerboard shows through, push it back up and the pattern disappears; whatever stays covered is the alpha, and the preview loop runs it from 100% down to 24% and back. The widest mover of the nine: accumulated area 13.06%, intensity 87.5, frames 18/23.

repeating-conic-gradientaria-valuetextcounter-set
// Top layer: transparent → opaque color. Bottom layer: 12px checker tile (four cells per tile, 6px each)
.al__track {
  background-image:
    linear-gradient(to right, rgba($subject-blue, 0), $subject-blue),
    repeating-conic-gradient(#d8d4cc 0 25%, $stage-paper 0 50%);
  background-size: auto, 12px 12px;
}
// Knob and number both come from --a alone — 1% of the 264px track is 2.64px
.al__knob { transform: translateX(calc(var(--a) * 2.64px)) translate(-50%, -50%); }
.al__val::after { content: counter(ca) "%"; }

05Recent colors: eight slots that shift forward

The history that makes a picked color reusable. Press the add button and a slot slides in at the front while the ninth drops off the end; the list survives in localStorage across page loads. Eight cells, each phase-shifted by 0.25 s, keep the colors flowing sideways and fill every frame — 23/23.

localStorageanimation-delayaria-pressed
// The list lives in localStorage. Opening from a file can block storage, so failures fall back
function read() {
  try {
    var list = JSON.parse(window.localStorage.getItem(storeKey));
    if (list && list.length) { return list.slice(0, 8); }
  } catch (err) { /* storage unavailable */ }
  return palette.slice();
}
recent.unshift(nowColor);
recent = recent.slice(0, 8);
// Each cell gets an animation-delay 0.25s apart. Eight colors stepping one cell sideways every
// 0.25s IS "one slots in at the front, the rest shift" — and after 2s exactly home, no teleport
.rc.is-demo .rc__cell:nth-child(2) { animation-delay: -1.75s; }
@keyframes histRoll {
  0%    { --c: #{$subject-blue}; }
  12.5% { --c: #{$subject-sky}; }
  25%   { --c: #{$subject-mint}; }
  50%   { --c: #{$stage-orange}; }
  100%  { --c: #{$subject-blue}; }
}

06Complementary, analogous and triadic sets from one hue

First-draft palettes. Complementary adds 180 degrees, analogous ±30 and ±60, triadic ±120, each building five chips — and nothing computes five colors anywhere: each chip holds a single "angle to add" and hsl() builds the color in place. Chips pop 40 ms apart to fill the frames between set swaps — accumulated area 9.759%, frames 20/23.

hslsteps(1, end)scale
// Five chips — fill and degree text both come from the same --oN
.hm__chip:nth-child(1) .hm__sw { background: hsl(calc(var(--base) + var(--o1)) 68% 70%); }
.hm__chip:nth-child(1) .hm__deg { counter-set: cd calc(var(--o1)); }
.hm__deg::after { content: counter(cd) "°"; }

// Complementary (0 0 0 180 180) → analogous (-60 -30 0 30 60) → triadic (-120 -120 0 120 120).
// Set swaps cut with steps(1, end) — a crossfade leaves ghost frames
@keyframes hmSet {
  0%        { --o1: 0;    --o2: 0;    --o3: 0; --o4: 180; --o5: 180; }
  33%       { --o1: -60;  --o2: -30;  --o3: 0; --o4: 30;  --o5: 60; }
  66%       { --o1: -120; --o2: -120; --o3: 0; --o4: 120; --o5: 120; }
  90%, 100% { --o1: 0;    --o2: 0;    --o3: 0; --o4: 180; --o5: 180; }
}

07Contrast checker that stamps AA and AAA

The formula this site itself runs in every accessibility table, extracted into a part. Compute relative luminance for text and background, take the ratio, and the AA and AAA badges light up the moment it clears 4.5 and 7, respectively. Its five-stage preview is the second-widest mover at 12.05%.

Math.powcounter-setaria-live
// WCAG 2.x relative luminance — un-gamma each channel, then weight by human eye sensitivity
function channel(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function luminance(hex) {
  var n = parseInt(hex.slice(1), 16);
  return 0.2126 * channel(((n >> 16) & 255) / 255)
       + 0.7152 * channel(((n >> 8) & 255) / 255)
       + 0.0722 * channel((n & 255) / 255);
}
// Text is fixed white, so the lighter luminance is always 1
function ratioToWhite(hex) { return 1.05 / (luminance(hex) + 0.05); }
// The ratio — whole part and two decimal digits printed separately to build 4.53
@counter-style dd { system: numeric; symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9"; pad: 2 "0"; }
.cc__ratio { counter-set: cw calc(var(--rw)) cf calc(var(--rf)); }
.cc__ratio::after { content: counter(cw) "." counter(cf, dd) " : 1"; }

08Gradient bar you build by dragging stops

Tune a hero background by eye. Drag the middle stop and its position reads out as a percentage while the linear-gradient string appears verbatim below, ready to copy — bar distribution, knob position, and the string all move together from one --p.

linear-gradientcounter-settranslateX
// The CSS string below — only the percentage seat is filled by a counter
.gs__bar {
  background-image: linear-gradient(90deg, $stage-orange 0%, $stage-yellow calc(var(--p) * 1%), $subject-blue 100%);
}
.gs__stop--m { transform: translateX(calc(var(--p) * 2.64px)) translate(-50%, -50%); }
.gs__code { counter-set: cp calc(var(--p)); }
.gs__code::after {
  content: "linear-gradient(90deg, #{$stage-orange} 0%, #{$stage-yellow} " counter(cp) "%, #{$subject-blue} 100%)";
}

09Eyedropper that lifts a pixel off the artwork

The axis for pulling a color out of a photo or screenshot. Move across the canvas artwork and getImageData reads that one pixel straight into the loupe and the HEX. The pick button uses the EyeDropper API where it exists and quietly falls back to picking on the canvas where it doesn't.

getImageDataEyeDroppertranslate
// Top 56px is a 3-stop horizontal band — per-channel linear interpolation, so x maps exactly to color
var band = ctx.createLinearGradient(0, 0, 264, 0);
band.addColorStop(0, '#ff4d1f');
band.addColorStop(0.5, '#ffd23f');
band.addColorStop(1, '#2f6df6');
var px = ctx.getImageData(sx, sy, 1, 1).data;   // that one pixel's RGBA

// Chromium picks from anywhere on screen. Browsers without it quietly fall back to the canvas
if ('EyeDropper' in window) {
  new window.EyeDropper().open().then(function (res) {
    var n = parseInt(res.sRGBHex.slice(1), 16);
    setColor((n >> 16) & 255, (n >> 8) & 255, n & 255);
  }).catch(function () { sample(); });
} else {
  sample();
}

Where it breaks — the trap

The first wall was color and text drifting apart. Version one of 01 had CSS paint the chip while JS wrote the HEX via textContent — during the preview loop, frames appeared where the color had already changed but the text lagged a beat. The moment those two paths split, a picker stops being trustworthy no matter how it looks. All nine now hold color and number in one registered property with counter-set carrying the value into digits. With the text-writing JS gone, there is no path left to disagree.

The second wall was interpolation references. The lightness loop in 03 first ran with $ease-spring; an easing curve does not apply the same ratio to position and color, so frames appeared where the RGB row and the HSL row pointed at different colors. Color space and easing correspond exactly only over linear interpolation — so 03 pins hue and saturation in the range where RGB is fully proportional to lightness and switches the easing to linear. The knob in 02 is the same story: conic-gradient(from 90deg) puts red at 3 o'clock and so does Math.atan2 — only after confirming the two references match can the angle go straight in as the hue.

The third was a tab's width, not its label. The tabs in 06 sit in a repeat(3, 1fr) grid, but a <button> still sizes to fit-content inside its cell, so until width: 100% was set the three tabs never filled the row evenly and the pressed one wobbled the moment its fill switched on. State swaps now cut in one frame with steps(1, end) — but cuts alone drop frames-moved to two or three, below the measurement floor, so the chips pop in a 40 ms stagger to fill the gaps.

The last is the geometry all nine parts share. Card width 288px, inner 264px — the four track-building parts (01, 04, 08, 09) all place their knob against that same 264px, and the two percent-driven ones (04 and 08) convert it with "1% of 264px is 2.64px". Slip that one line and the knob points somewhere the value doesn't. The nine parts and the measured values land in the zip exactly as this page ran them, and the password to open it is zg928jh3.

Accessibility (reduced-motion)

Under prefers-reduced-motion: reduce all nine follow one rule: the self-running preview loop stops and the picker itself stays complete. Static rules come first and the .is-demo loop is layered on top, so with animation off the knob in 01 is in place, the AA badge in 07 is lit, and the loupe in 09 stands on the artwork. Every declaration in the reduce block carries !important, because the loop side stacks classes and would otherwise win on specificity.

Six of the parts (01, 02, 04, 06, 08, 09) carry role="slider" with aria-valuetext, and where a color is involved it reads number and color in one sentence, such as "Saturation 94%, brightness 55%, #08468C". Arrow keys step small; Shift steps large. The no-color-only rule holds in 05 and 07 — a picked cell gets a ring plus its HEX label, and badges carry "pass" or "fail" words alongside. The ratio output in 07 is aria-live="polite" and re-reads on change.

Text contrast inside the pickers, computed with the same formula as part 07:

Spot Fore / back Ratio Verdict
01 card text ink / cream 17.11:1 passes AA and AAA
02 card text cream / ink 17.11:1 passes AA and AAA
03 card text ink / cream 17.11:1 passes AA and AAA
07 sample text (white) white / blue #2F6DF6 4.53:1 passes AA
09 stage text ink / orange 5.5:1 passes AA
08 code text (ink 74%) ink / cream 7.57:1 passes AA and AAA

The 4.53:1 row is a value the demo itself produces — of the five backgrounds 07 steps through, #2F6DF6 is the one that yields 4.53 against white text, the moment the AA threshold (4.5) is cleared. The formula follows MDN's color contrast guide and the W3C WCAG 2.x understanding docs.

More picking axes are collected in 9 Segmented Control UI Patterns and 9 Date Picker UI Patterns, and how this site renders and measures demos before writing is on the about page.

FAQ

What about browser support without a library?

Support for these techniques is wide. Measured on caniuse, 2026-09-12: @property sits at 95.01% global (Chrome 85+, Edge 85+, Firefox 128+, Safari 16.4+), counter-set at 95.27%, and @counter-style at 95.29%. The one narrow exception is the EyeDropper API in 09 at 30.8% global (Chrome and Edge 95+; no Firefox or Safari), which is why it checks for support first and falls back to picking on the canvas.

Why not just use <input type="color">?

For simple spots, you should. The native input draws a different window per browser and has no notation sync, no alpha, no harmony, no contrast check. These parts are for when those features are needed — even lifting 01's square on its own gets you a structure where the notation can never disagree with the knob.

Where do I change how many colors are saved?

Part 05 is the skeleton. The localStorage key name (godrich-recent-colors) and the 8 in slice(0, 8) set the list length. To persist server-side instead, change the one line inside save() — reads and writes already gather in a single place.

Enter the archive password

The password is inside this article. You will find it as you read.