ESC

Type to search the knowledge base.

Color Picker Widget

Machine-coding brief for a color picker — hex/RGB state, saturation canvas, hue slider, a11y, and controlled value.

intermediate6 min read
  • 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 value as #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
  • onChange on every drag; optional onChangeEnd

Nice to have

  • Alpha channel
  • Preset swatches
  • Contrast checker against a bg color

Planning (5 minutes out loud)

  1. Source of truth — store HSV or RGB internally; derive the rest
  2. Parse/serialize hex — reject incomplete while typing carefully (local draft vs committed)
  3. Pointer capture on SV canvas for drag outside bounds
  4. MVP — hex + RGB + native color input; then custom SV pad if time
  5. 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 onChange during drag only if parent is expensive
  • Avoid parsing hex on every parent render — memo conversions

Footguns

  1. Feedback loops — hex draft updating while dragging causes caret jumps (use draft state)
  2. Rounding drift — RGB → HSV → RGB off-by-one; round consistently at edges
  3. Missing # handling
  4. Pointer events without capture — drag breaks outside element
  5. Assuming native input type=color is 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.

Further reading