Color Picker Widget
Machine-coding brief for a color picker — hex/RGB state, saturation canvas, hue slider, a11y, and controlled value.
- machine-coding
- interview
- react
- canvas
Problem statement
Build a color picker widget: user picks a color via hue + saturation/value controls (or simplified RGB/hex inputs) and the component emits a normalized color string. Interviewers score bidirectional state sync (hex ↔ RGB ↔ HSV), pointer handling, and accessible inputs — not Photoshop-level gamut science.
Requirements
Must have
- Controlled
valueas#RRGGBB(define format up front) - Hex text input with validation
- RGB number inputs (0–255) synced with hex
- Visual swatch of current color
- One visual picker: either native
<input type="color">plus fields, or a custom SV square + hue slider - Keyboard-usable fields
Should have
- HSV/HSL sliders or 2D saturation-value area
- Copy-to-clipboard button
onChangeon every drag; optionalonChangeEnd
Nice to have
- Alpha channel
- Preset swatches
- Contrast checker against a bg color
Planning (5 minutes out loud)
- Source of truth — store HSV or RGB internally; derive the rest
- Parse/serialize hex — reject incomplete while typing carefully (local draft vs committed)
- Pointer capture on SV canvas for drag outside bounds
- MVP — hex + RGB + native color input; then custom SV pad if time
- A11y — don’t ship canvas-only without form fields
Architecture
ColorPicker
├── SaturationValuePad // canvas or div + gradients
├── HueSlider
├── HexField
├── RgbFields
└── Swatch + CopyButton
Data model
type RGB = { r: number; g: number; b: number };
type HSV = { h: number; s: number; v: number }; // h 0-360, s/v 0-1
type ColorPickerProps = {
value: string; // #RRGGBB
onChange: (hex: string) => void;
id?: string;
};
Color math sketch
export function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
export function hexToRgb(hex: string): RGB | null {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
export function rgbToHex({ r, g, b }: RGB) {
return (
"#" +
[r, g, b]
.map((x) => clamp(Math.round(x), 0, 255).toString(16).padStart(2, "0"))
.join("")
);
}
export function rgbToHsv({ r, g, b }: RGB): HSV {
const R = r / 255, G = g / 255, B = b / 255;
const max = Math.max(R, G, B), min = Math.min(R, G, B);
const d = max - min;
let h = 0;
if (d !== 0) {
switch (max) {
case R: h = ((G - B) / d + (G < B ? 6 : 0)) * 60; break;
case G: h = ((B - R) / d + 2) * 60; break;
default: h = ((R - G) / d + 4) * 60;
}
}
const s = max === 0 ? 0 : d / max;
return { h, s, v: max };
}
export function hsvToRgb({ h, s, v }: HSV): RGB {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let rp = 0, gp = 0, bp = 0;
if (h < 60) [rp, gp, bp] = [c, x, 0];
else if (h < 120) [rp, gp, bp] = [x, c, 0];
else if (h < 180) [rp, gp, bp] = [0, c, x];
else if (h < 240) [rp, gp, bp] = [0, x, c];
else if (h < 300) [rp, gp, bp] = [x, 0, c];
else [rp, gp, bp] = [c, 0, x];
return {
r: Math.round((rp + m) * 255),
g: Math.round((gp + m) * 255),
b: Math.round((bp + m) * 255),
};
}
Implementation sketch
Controlled fields without fighting the user
function HexField({
value,
onCommit,
}: {
value: string;
onCommit: (hex: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
return (
<input
value={draft}
aria-label="Hex color"
onChange={(e) => {
const t = e.target.value;
setDraft(t);
const rgb = hexToRgb(t.startsWith("#") ? t : `#${t}`);
if (rgb) onCommit(rgbToHex(rgb));
}}
onBlur={() => {
const rgb = hexToRgb(draft.startsWith("#") ? draft : `#${draft}`);
setDraft(rgb ? rgbToHex(rgb) : value);
}}
/>
);
}
SV pad (pointer)
function SvPad({
hsv,
onChange,
}: {
hsv: HSV;
onChange: (hsv: HSV) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
function pick(clientX: number, clientY: number) {
const rect = ref.current!.getBoundingClientRect();
const s = clamp((clientX - rect.left) / rect.width, 0, 1);
const v = clamp(1 - (clientY - rect.top) / rect.height, 0, 1);
onChange({ ...hsv, s, v });
}
return (
<div
ref={ref}
role="slider"
tabIndex={0}
aria-label="Saturation and brightness"
aria-valuetext={`Saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
className="sv-pad"
style={{
background: `
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, hsl(${hsv.h}, 100%, 50%))
`,
}}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
pick(e.clientX, e.clientY);
}}
onPointerMove={(e) => {
if (e.buttons !== 1) return;
pick(e.clientX, e.clientY);
}}
onKeyDown={(e) => {
const step = e.shiftKey ? 0.1 : 0.02;
if (e.key === "ArrowLeft") onChange({ ...hsv, s: clamp(hsv.s - step, 0, 1) });
if (e.key === "ArrowRight") onChange({ ...hsv, s: clamp(hsv.s + step, 0, 1) });
if (e.key === "ArrowDown") onChange({ ...hsv, v: clamp(hsv.v - step, 0, 1) });
if (e.key === "ArrowUp") onChange({ ...hsv, v: clamp(hsv.v + step, 0, 1) });
}}
/>
);
}
Wire: internal HSV derived from value; any control commits onChange(rgbToHex(hsvToRgb(hsv))).
Accessibility essentials
- Every channel has a labeled input; visual pad is additive, not exclusive
role="slider"+ keyboard on pad and hue- Live swatch with
aria-label={Current color ${hex}} - Don’t use color alone to indicate invalid hex — show text error
Performance notes
- SV pad via CSS gradients (no canvas redraw) is enough for interviews
- Throttle
onChangeduring drag only if parent is expensive - Avoid parsing hex on every parent render — memo conversions
Footguns
- Feedback loops — hex draft updating while dragging causes caret jumps (use draft state)
- Rounding drift — RGB → HSV → RGB off-by-one; round consistently at edges
- Missing
#handling - Pointer events without capture — drag breaks outside element
- Assuming native
input type=coloris enough — still need RGB/hex for precision and a11y story
Interview out-loud answer
I’d treat HSV as the interaction model and hex as the external contract. Converters keep RGB fields and hex in sync; text fields use a draft so partial typing doesn’t thrash. MVP is hex + RGB + native color input; if time allows, SV pad with pointer capture and arrow keys. Alpha and color spaces beyond sRGB are out of scope unless asked.