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.
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; // → trueGeometry
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.
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); // → 130Painting
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.
/**
* 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();
};
}