API
@pbroom/color-kit/react
React state and plane hooks on top of the driver: Color, useColor, useColorPlaneRenderer and geometry hooks.
42 exports13 values29 types18 re-exports
@pbroom/color-kit/react binds the engine to React state. This page builds
pickers from its hooks, which render no elements themselves: state hooks
that hold a color and share it, and plane hooks that paint a color plane
into your canvas and compute overlay geometry for your SVG. With the hooks,
elements, styles, pointer handling and ARIA are yours. Build them from plain
elements with
@pbroom/color-kit/driver, which holds the picker math (pointer
normalization, keyboard steps, value text), or with your own component
library.
State: one provider, two hooks
Color holds one color and shares it with every
component inside it. It takes the same options as
useColor, which returns that state with conversions
and setters (setRequested, setChannel, setFromString,
setActiveGamut, …) when a provider is more than you need. The provider
itself never re-renders on a color change. In your components,
useColorContext returns the full snapshot and
re-renders on every update;
useColorStoreSelector subscribes to one
field of the provider's store instead, so a drag re-renders only what depends
on it.
useMultiColor manages a named collection, such
as a palette or gradient stops, with one selected entry and a display gamut
and view shared by all entries. Its operations build on the latest state, so
several calls in one event handler compose.
Requested and displayed
Every state keeps two colors apart. requested is the exact OKLCH color the
user chose and may sit outside any screen's gamut; it is never clamped.
displayed is that color mapped into the active gamut ('display-p3' unless
you pass defaultGamut="srgb"), and displayedSrgb and displayedP3 hold
both mappings. Place a thumb at the requested color and paint it with the
displayed one, so it never jumps when a color leaves the gamut. The concept
page Requested vs displayed explains why.
Planes: a renderer and geometry hooks
Every plane hook takes the same plane: { color, axes }, where axes maps
two OKLCH channels to x and y (lightness across and chroma up by default) and
the third channel comes from color. Coordinates are normalized with y
pointing down, so the geometry hooks' path data fits an
<svg viewBox="0 0 100 100" preserveAspectRatio="none"> laid over the
canvas.
useColorPlaneRendererpaints the plane into a<canvas>you render. Attach itsrefand use itscanvasKeyas the canvaskey. It draws with WebGL, falls back to a CPU renderer when WebGL is missing or its context is lost, sizes the backing store from the canvas's CSS size and the device pixel ratio, and skips redraws that would paint the same pixels.useGamutBoundarytraces the sRGB or Display P3 edge for the plane's hue, anduseChromaBandthe line the current chroma follows across lightness.useContrastRegionsolves the colors that reach a WCAG or APCA level against a reference, as contour lines to stroke and closed polygons to fill.useFallbackPointsplaces the color after gamut mapping into sRGB and into Display P3.
useGamutBoundary, useChromaBand and useContrastRegion return
memoized points plus SVG path data from
toSvgPath. useFallbackPoints returns the srgb
and p3 points, without paths, and useColorPlaneRenderer returns a canvas
handle (ref, canvasKey, the active renderer). None of them render
elements.
A picker from the hooks
The picker below is built from plain elements: a canvas the renderer
paints, an SVG for the gamut edges, and a role="slider" thumb.
Pointer input goes through the driver's
createPointerDragController,
which coalesces moves to one commit per frame, and
colorFromColorAreaPosition turns
each normalized point into a color. The arrow keys step through
colorFromColorAreaKey, and
getColorAreaValueText writes the
thumb's aria-valuetext. The other examples on this page reuse its
PlanePicker.
- requested
- oklch(0.66 0.24 268)
- displayed
- oklch(0.66 0.1942 268)
- in sRGB
- no, mapped
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from 'react';
import { toCss, type Color } from '@pbroom/color-kit';
import {
colorFromColorAreaKey,
colorFromColorAreaPosition,
createPointerDragController,
getColorAreaThumbPosition,
getColorAreaValueText,
normalizeColorAreaPointer,
resolveColorAreaAxes,
type ColorAreaAxes,
} from '@pbroom/color-kit/driver';
import {
useAdaptiveQuality,
useColor,
useColorPlaneRenderer,
useGamutBoundary,
type ColorPlaneQualityLevel,
type SetRequestedOptions,
} from '@pbroom/color-kit/react';
/** What an overlay needs to query the plane the canvas paints. */
export interface PlaneOverlayProps {
color: Color;
axes: ColorAreaAxes;
isDragging: boolean;
quality: ColorPlaneQualityLevel;
}
export interface PlanePickerProps {
color: Color;
/** Color the thumb is painted with, usually the state's `displayed`. */
displayed?: Color;
onChange: (color: Color, options?: SetRequestedOptions) => void;
label: string;
axes?: ColorAreaAxes;
disabled?: boolean;
className?: string;
/** SVG drawn over the canvas in a `0 0 100 100` viewBox. */
children?: (plane: PlaneOverlayProps) => ReactNode;
}
type Pointer = { clientX: number; clientY: number };
const LC_AXES: ColorAreaAxes = {
x: { channel: 'l' },
y: { channel: 'c', range: [0, 0.37] },
};
/**
* A 2D picker built from plain elements: a canvas the plane renderer
* paints, an SVG for overlays, and a thumb. Pointer and keyboard input go
* through the driver; the hooks only compute.
*/
export function PlanePicker({
color,
displayed = color,
onChange,
label,
axes = LC_AXES,
disabled = false,
className = '',
children,
}: PlanePickerProps) {
const areaRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const lastFrame = useRef(0);
// The pointer that started the drag; other contacts are ignored.
const activePointer = useRef<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
// Disabling mid-drag ends the drag (the controller is cancelled below).
if (disabled && isDragging) {
setIsDragging(false);
}
// Lowers raster resolution and overlay sampling while frames are slow.
const { quality, reportFrame, reset } = useAdaptiveQuality('auto');
const { ref: canvasRef, canvasKey } = useColorPlaneRenderer(
{ color, axes },
{ quality, isDragging },
);
// The driver's drag controller coalesces moves to one commit per frame.
const [drag] = useState(() =>
createPointerDragController<Pointer>({
normalize: () => null,
commit: () => {},
}),
);
useEffect(() => {
const resolved = resolveColorAreaAxes(axes);
drag.configure({
normalize: ({ clientX, clientY }) => {
const rect = areaRef.current?.getBoundingClientRect();
return rect ? normalizeColorAreaPointer(clientX, clientY, rect) : null;
},
commit: (point) => {
if (disabled) return;
const start = performance.now();
onChange(
colorFromColorAreaPosition(color, resolved, point.x, point.y),
{ interaction: 'pointer' },
);
const end = performance.now();
reportFrame({
updateDurationMs: end - start,
frameTimeMs: lastFrame.current ? start - lastFrame.current : 0,
});
lastFrame.current = start;
},
});
}, [axes, color, disabled, drag, onChange, reportFrame]);
useEffect(() => () => drag.cancel(), [drag]);
// Disabled: drop the pending update and the drag, so nothing changes.
useEffect(() => {
if (!disabled) return;
drag.cancel();
activePointer.current = null;
}, [disabled, drag]);
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (disabled || event.button !== 0) return;
if (activePointer.current !== null) return; // a second finger
event.preventDefault();
activePointer.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
thumbRef.current?.focus({ preventScroll: true });
reset();
lastFrame.current = 0;
setIsDragging(true);
drag.start({ clientX: event.clientX, clientY: event.clientY });
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerId !== activePointer.current) return;
drag.move({ clientX: event.clientX, clientY: event.clientY });
};
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerId !== activePointer.current) return;
activePointer.current = null;
drag.end();
setIsDragging(false);
};
const resolved = resolveColorAreaAxes(axes);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const step = event.shiftKey ? 0.1 : 0.01;
const next = colorFromColorAreaKey(color, resolved, event.key, step);
if (!next) return;
event.preventDefault();
onChange(next, { interaction: 'keyboard' });
};
const thumb = getColorAreaThumbPosition(color, resolved);
return (
<div
ref={areaRef}
data-picker-area=""
data-dragging={isDragging || undefined}
className={`relative touch-none select-none ${className}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<canvas
ref={canvasRef}
key={canvasKey}
aria-hidden="true"
className="absolute inset-0 size-full rounded-[inherit]"
/>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
className="pointer-events-none absolute inset-0 size-full"
>
{children?.({ color, axes, isDragging, quality })}
</svg>
<div
ref={thumbRef}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-roledescription="2D slider"
aria-valuemin={Math.min(...resolved.x.range)}
aria-valuemax={Math.max(...resolved.x.range)}
aria-valuenow={color[resolved.x.channel]}
aria-valuetext={getColorAreaValueText(color, resolved)}
aria-disabled={disabled || undefined}
data-picker-thumb=""
data-x={thumb.x.toFixed(4)}
data-y={thumb.y.toFixed(4)}
onKeyDown={onKeyDown}
className="absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[#fff] shadow-[0_0_0_1px_rgb(0_0_0/0.45)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus-ring-color)]"
style={{
left: `${thumb.x * 100}%`,
top: `${thumb.y * 100}%`,
background: toCss(displayed, 'oklch'),
}}
/>
</div>
);
}
/** Dashed: the sRGB edge. Solid: the Display P3 edge. */
function GamutEdges({ color, axes, isDragging, quality }: PlaneOverlayProps) {
const query = { isDragging, quality };
const srgb = useGamutBoundary({ color, axes }, { gamut: 'srgb', ...query });
const p3 = useGamutBoundary(
{ color, axes },
{ gamut: 'display-p3', ...query },
);
return (
<g fill="none" stroke="#fff" strokeWidth={1.5}>
<path
d={srgb.path}
strokeDasharray="4 3"
vectorEffect="non-scaling-stroke"
/>
<path d={p3.path} vectorEffect="non-scaling-stroke" />
</g>
);
}
export default function PlanePickerExample() {
const {
requested,
displayed,
setRequested,
requestedCss,
displayedCss,
state,
} = useColor({ defaultColor: 'oklch(0.66 0.24 268)' });
return (
<div className="grid max-w-96 gap-4">
<PlanePicker
color={requested}
displayed={displayed}
onChange={setRequested}
label="Lightness and chroma"
className="aspect-[4/3] w-full rounded-md"
>
{(plane) => <GamutEdges {...plane} />}
</PlanePicker>
<dl className="grid grid-cols-[9ch_1fr] gap-x-3 gap-y-1 [font-family:var(--font-mono)] text-[12px]">
<dt className="text-muted-foreground">requested</dt>
<dd>{requestedCss('oklch')}</dd>
<dt className="text-muted-foreground">displayed</dt>
<dd>{displayedCss('oklch')}</dd>
<dt className="text-muted-foreground">in sRGB</dt>
<dd>{state.meta.outOfGamut.srgb ? 'no, mapped' : 'yes'}</dd>
</dl>
</div>
);
}Sliders and text
A single channel needs no plane: a native <input type="range"> already
handles keyboard and ARIA. Paint its rail with
getSliderGradientStyles, sampled
from the current color so each rail previews what dragging it would do, and
write with setChannel. For text,
formatColorStringInputValue and
parseColorStringInputValue
convert between the color and any CSS color string; commit with
setFromString, which keeps the hue of grays. The
React guide has a live example.
Controlled or uncontrolled
Uncontrolled, <Color defaultColor="…"> keeps the state itself. Controlled,
you pass state and onChange, and the provider only reports updates.
onChange fires synchronously inside the setter, once per effective update
and never for a no-op, and the event names the interaction (pointer,
keyboard, text-input, programmatic) and the changedChannel; pass
interaction to the setters so it is right. Build the initial state with
createColorState.
The React guide has a live controlled example.
Contrast regions
useContrastRegion outlines the colors of
the current hue that reach a contrast level against a reference, with WCAG 2
or APCA. Stroke path for the contour and fill fillPath for the passing
area. Pass the fixed background (or text) color as reference: without it
the region is measured against the plane's own color. Pick a background and
a level, then drag the thumb in and out of the region.
import { useId, useState } from 'react';
import { parse, type Color } from '@pbroom/color-kit';
import type { ColorAreaAxes } from '@pbroom/color-kit/driver';
import { useColor, useContrastRegion } from '@pbroom/color-kit/react';
import { PlanePicker, type PlaneOverlayProps } from './plane-picker';
const BACKGROUNDS = { white: parse('#ffffff'), black: parse('#000000') };
type Background = keyof typeof BACKGROUNDS;
type Level = 'AA' | 'AAA';
const AXES: ColorAreaAxes = {
x: { channel: 'l' },
y: { channel: 'c', range: [0, 0.3] },
};
/** Text colors of this hue that pass WCAG `level` on `reference`. */
function ContrastOverlay({
color,
axes,
isDragging,
quality,
reference,
level,
}: PlaneOverlayProps & { reference: Color; level: Level }) {
const region = useContrastRegion(
{ color, axes },
{ reference, level, isDragging, quality },
);
return (
<>
<path d={region.fillPath} fill="#fff" fillOpacity={0.24} />
<path
d={region.path}
fill="none"
stroke="#fff"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
</>
);
}
function Choice<T extends string>({
label,
options,
value,
onChange,
}: {
label: string;
options: readonly T[];
value: T;
onChange: (value: T) => void;
}) {
const name = useId();
return (
<fieldset className="m-0 flex items-center gap-2 border-0 p-0 text-[13px]">
<legend className="float-left mr-1 p-0 text-muted-foreground">
{label}
</legend>
{options.map((option) => (
<label key={option} className="flex items-center gap-2">
<input
type="radio"
name={name}
checked={value === option}
onChange={() => onChange(option)}
/>
{option}
</label>
))}
</fieldset>
);
}
export default function ContrastRegionExample() {
const [background, setBackground] = useState<Background>('white');
const [level, setLevel] = useState<Level>('AA');
const { requested, displayed, setRequested } = useColor({
defaultColor: 'oklch(0.5 0.16 150)',
});
return (
<div className="grid max-w-96 gap-3">
<PlanePicker
color={requested}
displayed={displayed}
onChange={setRequested}
label="Text color lightness and chroma"
axes={AXES}
className="aspect-[4/3] w-full rounded-md"
>
{(plane) => (
<ContrastOverlay
{...plane}
reference={BACKGROUNDS[background]}
level={level}
/>
)}
</PlanePicker>
<Choice
label="Background"
options={['white', 'black'] as const}
value={background}
onChange={setBackground}
/>
<Choice
label="Level"
options={['AA', 'AAA'] as const}
value={level}
onChange={setLevel}
/>
</div>
);
}Accessibility
The hooks render nothing, so the accessible elements are yours. The driver supplies the parts a picker needs:
- Roles and value text. Give a 2D thumb
role="slider"witharia-roledescription="2D slider",aria-valuenowfor the x axis andaria-valuetextfromgetColorAreaValueText, such as "Lightness 60%, Chroma 0.2". For a custom 1D slider,getColorSliderValueTextnames the channel and unit ("Hue 213°"). Label each witharia-label. - Keyboard.
colorFromColorAreaKeymaps the arrow keys, PageUp/PageDown and Home/End to a new color by a step ratio of the range; hue wraps. Focus the thumb on pointer down so a click followed by the arrow keys fine-tunes. - Canvas and overlays. Mark the canvas and the overlay SVG
aria-hidden: the thumb carries the value.
Performance
useAdaptiveQualitylowers a quality level while measured frames are slow and raises it when they recover. Report each committed drag frame to it and pass itsqualityto the renderer (fewer pixels) and the geometry hooks (coarser sampling).- Pass
isDraggingto the plane hooks: the renderer drops a little resolution, and the geometry hooks solve in a shared Web Worker, keeping the last result on screen until a fresh one arrives. At rest they solve synchronously. - Moving along an axis channel does not repaint the plane, and the geometry
hooks key on channel values, so inline
{ color, axes }objects are free. - Read single fields with
useColorStoreSelectorrather thanuseColorContextin components that re-render often.
The guide Building with @pbroom/color-kit/react assembles these parts into one picker.
Color
- const
ColorContextReact context that a Color provider fills with its shared color state; null outside a provider. - function
useColorContextReturns the nearest Color provider's state as a live UseColorReturn snapshot (requested/displayed colors, conversions and setters). - component
ColorProvides shared color state to every component inside it. - function
useColorHolds one color as requested/displayed state and returns it with conversions and setters. - function
useMultiColorManages a named collection of colors that share one display gamut and view model, plus a selected entry. - function
createColorStoreCreates a minimal external store holding a ColorState. - function
useColorStoreSelectorSubscribes a component to a slice of a (possibly absent) color store and returns the selected value. - type
ColorContextValueValue shared by a Color provider; the same shape useColor returns. - interface
ColorPropsProps for Color: the UseColorOptions plus children. - interface
SetRequestedOptionsOptions for the UseColorReturn and UseMultiColorReturn setters. - interface
UseColorOptionsOptions for useColor and the <Color> provider. - interface
UseColorReturnColor state, conversions and setters returned by useColor and useColorContext. - interface
UseMultiColorOptionsOptions for useMultiColor. - interface
UseMultiColorReturnCollection state and operations returned by useMultiColor. - interface
ColorStoreMinimal external store for shared color state.
Plane renderer
- function
useColorPlaneRendererRasterizes a color plane into a <canvas> you render: every pixel is the plane's color with the two axis channels set to that position. - type
ActiveColorPlaneRendererRasterizer actually in use: WebGL ('gpu') or a 2D canvas ('cpu'). - type
ColorPlaneEdgeBehaviorHow a displayed plane paints colors outside the display gamut. - type
ColorPlaneRendererRasterizer for useColorPlaneRenderer: 'gpu' (WebGL), 'cpu' (2D canvas), or 'auto' (WebGL with a CPU fallback). - interface
ColorPlaneRendererHandleReturn value of useColorPlaneRenderer. - type
ColorPlaneSourceWhich colors useColorPlaneRenderer paints: the raw requested plane colors (each channel clipped to sRGB) or the displayed colors for the display gamut. - interface
UseColorPlaneRendererOptionsOptions for useColorPlaneRenderer.
Plane geometry
- function
useGamutBoundaryTraces the gamut boundary (the maximum in-gamut chroma at each lightness) for one hue, as points and SVG path data in plane coordinates. - function
useChromaBandSamples the plane color's chroma as a line across lightness, kept inside the gamut: the tonal strip the current chroma traces for its hue. - function
useContrastRegionSolves the colors of one hue that meet a contrast threshold against a reference color, as contour lines and fillable polygons in plane coordinates. - function
useFallbackPointsPositions of the plane color after gamut mapping into sRGB and into Display P3, e.g. to mark where an out-of-gamut pick will actually render. - interface
ColorPlaneLineGeometryGeometry returned by useGamutBoundary and useChromaBand. - type
ColorPlaneLinePointOne vertex of a plane line: OKLCH l/c and normalized x/y (y down). - interface
UseGamutBoundaryOptionsOptions for useGamutBoundary. - interface
UseChromaBandOptionsOptions for useChromaBand. - interface
ContrastRegionGeometryGeometry returned by useContrastRegion. - interface
ContrastRegionMetricsPer-compute stats passed to UseContrastRegionOptions.onMetrics. - interface
UseContrastRegionOptionsOptions for useContrastRegion. - interface
ColorPlaneFallbackPointsReturn value of useFallbackPoints. - interface
ColorPlanePixelSizePlane size in device pixels, used to derive adaptive sampling budgets. - interface
ColorPlaneQueryOptionsInteraction options shared by the geometry hooks (useGamutBoundary, useChromaBand, useContrastRegion). - interface
ColorPlaneSpecThe OKLCH plane a plane hook works on: a color and the two channels mapped to x and y.
Performance
- function
useAdaptiveQualityAdapts a ColorPlaneQualityLevel to measured interaction cost. - interface
AdaptiveQualityReturn value of useAdaptiveQuality. - interface
AdaptiveQualityFrameOne measured interaction frame, passed to reportFrame. - type
ColorPlanePerformanceProfileHow eagerly useAdaptiveQuality trades detail for speed. 'quality' always stays at 'high'; 'auto', 'balanced' and 'performance' lower the level when frames are slow, with progressively earlier degradation (and 'performance' starts at 'medium'). - type
ColorPlaneQualityLevelDetail level for the plane hooks: raster resolution in useColorPlaneRenderer and sampling density in the geometry hooks such as useGamutBoundary.
Re-exported here
@pbroom/color-kit/react also exports 18 symbols documented in a more specific entry point. Import them from either path; they are the same declarations.
@pbroom/color-kit/driver18- MultiColorEntryInput
- MultiColorInput
- MultiColorState
- MultiColorUpdateEvent
- ColorAreaAxes
- ColorAreaAxis
- ColorAreaChannel
- ColorAreaContrastRegionOptions
- ColorAreaContrastRegionPoint
- ColorAreaFallbackPoint
- ColorAreaGamutBoundaryPoint
- ColorChannel
- ColorInteraction
- ColorSource
- ColorState
- ColorUpdateEvent
- GamutTarget
- ViewModel