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.

  • useColorPlaneRenderer paints the plane into a <canvas> you render. Attach its ref and use its canvasKey as the canvas key. 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.
  • useGamutBoundary traces the sRGB or Display P3 edge for the plane's hue, and useChromaBand the line the current chroma follows across lightness.
  • useContrastRegion solves the colors that reach a WCAG or APCA level against a reference, as contour lines to stroke and closed polygons to fill.
  • useFallbackPoints places 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
plane-picker.tsx
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>
  );
}
A plane picker from hooks

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.

Background
Level
contrast-region.tsx
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>
  );
}
useContrastRegion

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" with aria-roledescription="2D slider", aria-valuenow for the x axis and aria-valuetext from getColorAreaValueText, such as "Lightness 60%, Chroma 0.2". For a custom 1D slider, getColorSliderValueText names the channel and unit ("Hue 213°"). Label each with aria-label.
  • Keyboard. colorFromColorAreaKey maps 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

  • useAdaptiveQuality lowers a quality level while measured frames are slow and raises it when they recover. Report each committed drag frame to it and pass its quality to the renderer (fewer pixels) and the geometry hooks (coarser sampling).
  • Pass isDragging to 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 useColorStoreSelector rather than useColorContext in components that re-render often.

The guide Building with @pbroom/color-kit/react assembles these parts into one picker.

Color

Plane renderer

Plane geometry

Performance

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.