Guides

A headless color picker

Build a picker in any framework, or none, from @pbroom/color-kit/driver's state reducers, geometry, keyboard model and drag controller.

What the driver owns

@pbroom/color-kit/driver is a picker without a view: pure functions over a ColorState, plus the geometry, keyboard and pointer models a picker needs. @pbroom/color-kit/react is one binding built on it. The example on this page is another, in plain DOM code, so it works under any framework.

State

createColorState builds the state from a requested color: the request, its sRGB and Display P3 mappings, the active gamut and view, and out-of-gamut flags. Four reducers update it: setColorRequested, setColorChannel, setColorActiveGamut and setColorActiveView. Each returns the same object for a no-op, so next === state is enough to skip a render. createColorState and setColorActiveGamut check the gamut with assertGamutTarget, so 'p3' throws the same TypeError as the plane queries.

state.ts
import { parse, toHex } from '@pbroom/color-kit';
import { createColorState, setColorChannel } from '@pbroom/color-kit/driver';

const state = createColorState(parse('#3b82f6'));
const next = setColorChannel(state, 'l', 0.4, 'user');
toHex(next.requested); // → '#003baa'
setColorChannel(next, 'l', 0.4, 'user') === next; // → true

Geometry

A 2D area maps two OKLCH channels to a rectangle. resolveColorAreaAxes defaults to lightness on x and chroma on y. Pointer input goes through normalizeColorAreaPointer (client pixels to clamped [0, 1], y down) and colorFromColorAreaPosition; getColorAreaThumbPosition goes back. Sliders have the same trio: normalizeColorSliderPointer, colorFromColorSliderPosition and getColorSliderThumbPosition.

Keyboard and ARIA

colorFromColorAreaKey and colorFromColorSliderKey implement arrows, Page Up/Down and Home/End, wrapping hue and clamping the rest. They return null for keys they do not handle, so call preventDefault only when they return a color. getColorAreaValueText and getColorSliderValueText produce aria-valuetext such as Lightness 60%, Chroma 0.1 and Hue 213°. A slider exposes one number, so the area thumb sets aria-valuenow, aria-valuemin and aria-valuemax from the x axis and aria-roledescription="2D slider"; the value text names both axes.

Drag throttling

createPointerDragController commits start and end immediately and coalesces moves to at most one commit per animation frame. Moves within dragEpsilon (default 0.0005) are skipped and commits are capped at maxUpdateHz (default 60); a rate-limited point is retried on the next frame, never dropped. Pass a frame scheduler as the second argument to drive drags deterministically in tests.

The controller tracks one drag, not one pointer. The binding records the pointerId that started the drag, captures it, and ignores other pointers until that one lifts or is cancelled, so a second finger cannot steer or end the drag.

Typed input

parseColorInputExpression turns text typed into a channel field into a number; resolveColorInputDraftValue then clamps or wraps it. A % is a position in the channel's range, also inside an expression, while input that starts with an operator is relative to the current value and its % is a share of the span.

input.ts
import { parseColorInputExpression } from '@pbroom/color-kit/driver';

const options = {
  currentValue: 120,
  range: [100, 200] as [number, number],
  allowExpressions: true,
};
parseColorInputExpression('50%', options); // → 150
parseColorInputExpression('50% * 2', options); // → 300
parseColorInputExpression('+10%', options); // → 130

Painting

getSliderGradientStyles returns linear-gradient() strings for a rail, in Display P3 by default with an sRGB fallback. getColorDisplayStyles returns a background and backgroundColor pair: P3 where the browser supports it, sRGB underneath. For overlays, getColorAreaGamutBoundaryPoints and getColorAreaContrastRegionPaths return plane geometry in area coordinates.

The picker

bindPicker takes six elements and returns a cleanup function. Drag the area or the rail, or focus a thumb and use the arrow keys (Shift for larger steps). A React component renders the markup and calls it from an effect.

headless-picker.ts
/**
 * A color picker with no framework: @pbroom/color-kit/driver supplies the state,
 * geometry, keyboard model and drag throttling; this file only touches the
 * DOM. Any binding (React, Vue, Svelte, web components) can wrap it.
 */
import {
  toRgbInto,
  toSrgbGamutInto,
  type Color,
  type Rgb,
} from '@pbroom/color-kit';
import {
  colorFromColorAreaKey,
  colorFromColorAreaPosition,
  colorFromColorSliderKey,
  colorFromColorSliderPosition,
  createColorState,
  createPointerDragController,
  getColorAreaThumbPosition,
  getColorAreaValueText,
  getColorDisplayStyles,
  getColorSliderThumbPosition,
  getColorSliderValueText,
  getSliderGradientStyles,
  normalizeColorAreaPointer,
  normalizeColorSliderPointer,
  resolveColorAreaAxes,
  setColorRequested,
  type ColorState,
} from '@pbroom/color-kit/driver';

export interface PickerElements {
  /** 2D area: lightness on x, chroma on y. Contains `canvas` and `thumb`. */
  area: HTMLElement;
  canvas: HTMLCanvasElement;
  thumb: HTMLElement;
  /** Hue rail and its thumb. */
  hue: HTMLElement;
  hueThumb: HTMLElement;
  swatch: HTMLElement;
}

const axes = resolveColorAreaAxes(); // x: l [0, 1], y: c [0, 0.4]
const HUE: [number, number] = [0, 360];

export function bindPicker(
  el: PickerElements,
  initial: Color,
  onChange?: (state: ColorState) => void,
): () => void {
  let state = createColorState(initial);
  let paintedHue = Number.NaN;

  const commit = (next: Color) => {
    const updated = setColorRequested(state, next, 'user');
    if (updated === state) return; // reducers return the same object on a no-op
    state = updated;
    render();
    onChange?.(state);
  };

  // Paint the plane at the current hue, mapped into sRGB pixel by pixel.
  const pixel: Color = { l: 0, c: 0, h: 0, alpha: 1 };
  const rgb: Rgb = { r: 0, g: 0, b: 0, alpha: 1 };
  function paintPlane(hue: number) {
    const { width, height } = el.canvas;
    const ctx = el.canvas.getContext('2d');
    if (!ctx) return;
    const image = ctx.createImageData(width, height);
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const color = colorFromColorAreaPosition(
          { l: 0, c: 0, h: hue, alpha: 1 },
          axes,
          x / (width - 1),
          y / (height - 1),
        );
        toRgbInto(rgb, toSrgbGamutInto(pixel, color));
        const i = (y * width + x) * 4;
        image.data[i] = rgb.r;
        image.data[i + 1] = rgb.g;
        image.data[i + 2] = rgb.b;
        image.data[i + 3] = 255;
      }
    }
    ctx.putImageData(image, 0, 0);
    paintedHue = hue;
  }

  function render() {
    const { requested, displayed, activeGamut } = state;
    const { x, y } = getColorAreaThumbPosition(requested, axes);
    el.thumb.style.left = `${x * 100}%`;
    el.thumb.style.top = `${y * 100}%`;
    // A slider exposes one number, so valuenow is the x axis (lightness);
    // valuetext names both axes.
    el.thumb.setAttribute('aria-valuenow', String(requested[axes.x.channel]));
    el.thumb.setAttribute(
      'aria-valuetext',
      getColorAreaValueText(requested, axes),
    );

    const hue = getColorSliderThumbPosition(requested, 'h', HUE);
    el.hueThumb.style.left = `${hue * 100}%`;
    el.hue.setAttribute('aria-valuenow', String(Math.round(requested.h)));
    el.hue.setAttribute(
      'aria-valuetext',
      getColorSliderValueText('h', requested.h),
    );

    // Display P3 where supported, with an sRGB fallback underneath.
    Object.assign(
      el.swatch.style,
      getColorDisplayStyles(displayed.p3, displayed.srgb, activeGamut),
    );
    if (requested.h !== paintedHue) paintPlane(requested.h);
  }

  // Pointer moves are coalesced to one commit per frame.
  const areaDrag = createPointerDragController<PointerEvent>({
    normalize: (e) =>
      normalizeColorAreaPointer(
        e.clientX,
        e.clientY,
        el.area.getBoundingClientRect(),
      ),
    commit: ({ x, y }) =>
      commit(colorFromColorAreaPosition(state.requested, axes, x, y)),
  });
  const hueDrag = createPointerDragController<PointerEvent>({
    normalize: (e) => {
      const rect = el.hue.getBoundingClientRect();
      return {
        x: normalizeColorSliderPointer(
          'horizontal',
          e.clientX,
          rect.left,
          rect.width,
        ),
        y: 0,
      };
    },
    commit: ({ x }) =>
      commit(colorFromColorSliderPosition(state.requested, 'h', x, HUE)),
  });

  // One AbortController removes every listener on cleanup.
  const listeners = new AbortController();
  const on = <K extends keyof HTMLElementEventMap>(
    target: HTMLElement,
    type: K,
    handler: (e: HTMLElementEventMap[K]) => void,
  ) => target.addEventListener(type, handler, { signal: listeners.signal });

  // Each drag follows the pointer that started it: a second finger is
  // ignored until that pointer lifts, and capture keeps its moves coming
  // after it leaves the element.
  for (const [target, drag] of [
    [el.area, areaDrag],
    [el.hue, hueDrag],
  ] as const) {
    let activePointer: number | null = null;
    const finish = (e: PointerEvent, cancel: boolean) => {
      if (e.pointerId !== activePointer) return;
      activePointer = null;
      if (cancel) drag.cancel();
      else drag.end();
    };
    on(target, 'pointerdown', (e) => {
      if (activePointer !== null) return;
      activePointer = e.pointerId;
      target.setPointerCapture(e.pointerId);
      drag.start(e);
    });
    on(target, 'pointermove', (e) => {
      if (e.pointerId === activePointer) drag.move(e);
    });
    on(target, 'pointerup', (e) => finish(e, false));
    on(target, 'pointercancel', (e) => finish(e, true));
    // Capture can be lost without a pointerup (e.g. the element is hidden).
    on(target, 'lostpointercapture', (e) => finish(e, false));
  }

  // Arrow keys step 1% of the range, Shift steps 10%.
  on(el.thumb, 'keydown', (e) => {
    const next = colorFromColorAreaKey(
      state.requested,
      axes,
      e.key,
      e.shiftKey ? 0.1 : 0.01,
    );
    if (next) {
      e.preventDefault();
      commit(next);
    }
  });
  on(el.hue, 'keydown', (e) => {
    const next = colorFromColorSliderKey(
      state.requested,
      'h',
      e.key,
      e.shiftKey ? 0.1 : 0.01,
      HUE,
    );
    if (next) {
      e.preventDefault();
      commit(next);
    }
  });

  const [xMin, xMax] = axes.x.range;
  el.thumb.setAttribute('aria-roledescription', '2D slider');
  el.thumb.setAttribute('aria-valuemin', String(Math.min(xMin, xMax)));
  el.thumb.setAttribute('aria-valuemax', String(Math.max(xMin, xMax)));

  el.hue.style.background = getSliderGradientStyles({
    model: 'oklch',
    channel: 'h',
    baseColor: state.requested,
    range: HUE,
    colorSpace: 'srgb',
  }).activeBackgroundImage;
  render();

  return () => {
    areaDrag.cancel();
    hueDrag.cancel();
    listeners.abort();
  };
}