Guides

Building with @pbroom/color-kit/react

Build a picker from color-kit's React hooks and plain elements, then choose between uncontrolled and controlled state.

The pieces

The hooks in @pbroom/color-kit/react render no elements. They hold state and compute what a picker draws; the elements, their styles and their input handling are yours. Build them from plain HTML with @pbroom/color-kit/driver, which holds the picker math, or host them in your own components.

The API page builds the PlanePicker this guide uses, a canvas, an SVG and a thumb wired to those functions, in one file.

Uncontrolled

<Color defaultColor="…"> keeps the state itself. The provider does not re-render on color changes; useColorContext returns the full snapshot and setters and re-renders on every update. For one field, select it from the store with useColorStoreSelector.

Controlled

Pass state and onChange to own the state. onChange fires synchronously inside the setter, once per effective update and never for a no-op. Several setter calls in one event compose, each building on the last.

controlled.tsx
import { useState } from 'react';
import { parse } from '@pbroom/color-kit';
import { createColorState } from '@pbroom/color-kit/driver';
import {
  Color,
  useColorContext,
  useColorPlaneRenderer,
} from '@pbroom/color-kit/react';

function Plane() {
  const { requested } = useColorContext();
  const { ref, canvasKey } = useColorPlaneRenderer({ color: requested });
  return (
    <canvas ref={ref} key={canvasKey} style={{ width: 240, height: 160 }} />
  );
}

export function ControlledPicker() {
  const [state, setState] = useState(() => createColorState(parse('#3b82f6')));
  return (
    <Color state={state} onChange={(event) => setState(event.next)}>
      <Plane />
    </Color>
  );
}

useColor takes the same options and returns the state with its setters (setRequested, setChannel, setFromString, setActiveGamut, …) for when a provider is more than you need; the plane hooks only need a color, so they work with either. For palettes and gradient stops, useMultiColor manages named entries with a shared active gamut.

The live example below is controlled: it records each update's interaction and changedChannel, and the disabled switch stops pointer and keyboard input.

no updates yet: drag, or focus the thumb and use the arrow keys

color-provider.tsx
import { useRef, useState } from 'react';
import { parse } from '@pbroom/color-kit';
import { createColorState } from '@pbroom/color-kit/driver';
import {
  Color,
  useColorContext,
  type ColorInteraction,
  type ColorUpdateEvent,
} from '@pbroom/color-kit/react';
import { PlanePicker } from './plane-picker';

const START = createColorState(parse('oklch(0.62 0.19 30)'));

/** Any component under the provider reads and writes the shared color. */
function ProviderPicker({ disabled }: { disabled: boolean }) {
  const { requested, displayed, setRequested, setChannel } = useColorContext();
  const input = useRef<ColorInteraction>('pointer');
  return (
    <>
      <PlanePicker
        color={requested}
        displayed={displayed}
        onChange={setRequested}
        label="Lightness and chroma"
        disabled={disabled}
        className="aspect-[2/1] w-full rounded-md data-[dragging]:cursor-grabbing"
      />
      <input
        type="range"
        aria-label="Hue"
        min={0}
        max={360}
        step={1}
        disabled={disabled}
        value={Math.round(requested.h)}
        // A native range reports keyboard and pointer changes alike; note
        // which one started it so the update event names it.
        onPointerDown={() => {
          input.current = 'pointer';
        }}
        onKeyDown={() => {
          input.current = 'keyboard';
        }}
        onChange={(event) =>
          setChannel('h', Number(event.currentTarget.value), {
            interaction: input.current,
          })
        }
      />
    </>
  );
}

export default function ControlledColor() {
  // Controlled: this component owns the state; <Color> only reports updates.
  const [state, setState] = useState(START);
  const [last, setLast] = useState<ColorUpdateEvent | null>(null);
  const [disabled, setDisabled] = useState(false);

  return (
    <Color
      state={state}
      onChange={(event) => {
        setState(event.next);
        setLast(event);
      }}
    >
      <div className="grid max-w-96 gap-4">
        <ProviderPicker disabled={disabled} />
        <div className="flex flex-wrap items-center gap-4 text-[13px]">
          <label className="flex items-center gap-2">
            <input
              type="checkbox"
              checked={disabled}
              onChange={(event) => setDisabled(event.currentTarget.checked)}
            />
            Disabled
          </label>
          <button
            type="button"
            className="rounded-sm border border-border px-2 py-0.5"
            onClick={() => {
              setState(START);
              setLast(null);
            }}
          >
            Reset
          </button>
        </div>
        <p className="m-0 [font-family:var(--font-mono)] text-[12px]">
          {last
            ? `last update: ${last.interaction}, channel ${last.changedChannel ?? '(none)'}`
            : 'no updates yet: drag, or focus the thumb and use the arrow keys'}
        </p>
      </div>
    </Color>
  );
}
A controlled Color provider

The picker

The default color is outside sRGB. The dashed line is the sRGB edge; check Preview in sRGB and the plane and the displayed readout switch to the sRGB mapping while the requested color stays put. The hue slider is a native range input painted with getSliderGradientStyles.

requested oklch(0.7 0.25 150)displayed oklch(0.7 0.25 150)
react-picker.tsx
import { useRef } from 'react';
import { getSliderGradientStyles } from '@pbroom/color-kit/driver';
import {
  Color,
  useColorContext,
  useGamutBoundary,
  type ColorInteraction,
} from '@pbroom/color-kit/react';
import {
  PlanePicker,
  type PlaneOverlayProps,
} from '@/examples/react/plane-picker';

// Paint the hue rail once, in Display P3 with an sRGB fallback.
const hueRail = getSliderGradientStyles({
  model: 'oklch',
  channel: 'h',
  baseColor: { l: 0.7, c: 0.15, h: 0, alpha: 1 },
  range: [0, 360],
});

/** The sRGB edge, dashed, from the same plane the canvas paints. */
function SrgbEdge({ color, axes, isDragging, quality }: PlaneOverlayProps) {
  const edge = useGamutBoundary(
    { color, axes },
    { gamut: 'srgb', isDragging, quality },
  );
  return (
    <path
      d={edge.path}
      fill="none"
      stroke="#fff"
      strokeDasharray="3 3"
      vectorEffect="non-scaling-stroke"
    />
  );
}

function Picker() {
  const {
    requested,
    displayed,
    setRequested,
    setChannel,
    activeGamut,
    setActiveGamut,
    requestedCss,
    displayedCss,
  } = useColorContext();
  const input = useRef<ColorInteraction>('pointer');
  return (
    <div className="grid max-w-80 gap-3">
      <PlanePicker
        color={requested}
        displayed={displayed}
        onChange={setRequested}
        label="Lightness and chroma"
        className="aspect-[3/2] w-full rounded-sm"
      >
        {(plane) => <SrgbEdge {...plane} />}
      </PlanePicker>
      <input
        type="range"
        aria-label="Hue"
        min={0}
        max={360}
        step={0.1}
        value={requested.h}
        // A native range reports keyboard and pointer changes alike; note
        // which one started it so the update event names it.
        onPointerDown={() => {
          input.current = 'pointer';
        }}
        onKeyDown={() => {
          input.current = 'keyboard';
        }}
        onChange={(event) =>
          setChannel('h', Number(event.currentTarget.value), {
            interaction: input.current,
          })
        }
        className="h-3 w-full appearance-none rounded-full [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[#fff] [&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-[#fff] [&::-moz-range-thumb]:bg-transparent"
        style={{
          backgroundColor: hueRail.srgbBackgroundColor,
          backgroundImage: hueRail.activeBackgroundImage,
        }}
      />
      <div className="grid gap-1 [font-family:var(--font-mono)] text-[13px]">
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={activeGamut === 'srgb'}
            onChange={(event) =>
              setActiveGamut(
                event.currentTarget.checked ? 'srgb' : 'display-p3',
              )
            }
          />
          Preview in sRGB
        </label>
        <span>requested {requestedCss('oklch')}</span>
        <span>displayed {displayedCss('oklch')}</span>
      </div>
    </div>
  );
}

export default function ReactPicker() {
  return (
    <Color defaultColor="oklch(0.7 0.25 150)">
      <Picker />
    </Color>
  );
}

Sliders and text

A single channel needs no plane. A native <input type="range"> already handles the 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 a CSS color string. Commit with setFromString on Enter or blur, and drop text that does not parse.

channel-inputs.tsx
import { useRef, useState } from 'react';
import {
  formatColorStringInputValue,
  getSliderGradientStyles,
  parseColorStringInputValue,
} from '@pbroom/color-kit/driver';
import {
  Color,
  useColorContext,
  type ColorInteraction,
} from '@pbroom/color-kit/react';

const CHANNELS = [
  { channel: 'l', label: 'Lightness', range: [0, 1], step: 0.001 },
  { channel: 'c', label: 'Chroma', range: [0, 0.37], step: 0.001 },
  { channel: 'h', label: 'Hue', range: [0, 360], step: 0.1 },
] as const;

// Native range inputs bring their own keyboard and ARIA; this only paints
// the track and the thumb.
const range =
  'h-3.5 w-full cursor-pointer appearance-none rounded-full [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-[#fff] [&::-moz-range-thumb]:bg-transparent [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[#fff] [&::-webkit-slider-thumb]:shadow-[0_0_0_1px_rgb(0_0_0/0.45)]';

function ChannelSlider({
  channel,
  label,
  range: bounds,
  step,
}: (typeof CHANNELS)[number]) {
  const { requested, activeGamut, setChannel } = useColorContext();
  const input = useRef<ColorInteraction>('pointer');
  // Sample the rail from the current color, so it previews what dragging
  // this channel would produce.
  const rail = getSliderGradientStyles({
    model: 'oklch',
    channel,
    baseColor: requested,
    range: [bounds[0], bounds[1]],
    colorSpace: activeGamut,
  });
  return (
    <label className="grid grid-cols-[9ch_1fr] items-center gap-3 text-[13px]">
      <span className="text-muted-foreground">{label}</span>
      <input
        type="range"
        min={bounds[0]}
        max={bounds[1]}
        step={step}
        value={requested[channel]}
        // A native range reports keyboard and pointer changes alike; note
        // which one started it so the update event names it.
        onPointerDown={() => {
          input.current = 'pointer';
        }}
        onKeyDown={() => {
          input.current = 'keyboard';
        }}
        onChange={(event) =>
          setChannel(channel, Number(event.currentTarget.value), {
            interaction: input.current,
          })
        }
        className={range}
        style={{
          backgroundColor: rail.srgbBackgroundColor,
          backgroundImage: rail.activeBackgroundImage,
        }}
      />
    </label>
  );
}

/** Commits any CSS color on Enter or blur; Escape and invalid text revert. */
function ColorText() {
  const { requested, setFromString } = useColorContext();
  const [draft, setDraft] = useState<string | null>(null);
  const shown = draft ?? formatColorStringInputValue(requested, 'oklch');
  const commit = () => {
    if (draft === null) return;
    // Invalid text is dropped, so the field shows the current color again.
    if (parseColorStringInputValue(draft)) setFromString(draft);
    setDraft(null);
  };
  return (
    <input
      aria-label="Color"
      value={shown}
      aria-invalid={
        draft !== null && parseColorStringInputValue(draft) === null
      }
      onChange={(event) => setDraft(event.currentTarget.value)}
      onBlur={commit}
      onKeyDown={(event) => {
        if (event.key === 'Enter') commit();
        if (event.key === 'Escape') setDraft(null);
      }}
      className="w-full rounded-sm border border-border bg-transparent px-2 py-1 [font-family:var(--font-mono)] text-[13px] aria-[invalid=true]:border-[var(--danger,#d33)]"
    />
  );
}

export default function ChannelInputs() {
  return (
    <Color defaultColor="oklch(0.7 0.16 45)">
      <div className="grid max-w-96 gap-4">
        {CHANNELS.map((entry) => (
          <ChannelSlider key={entry.channel} {...entry} />
        ))}
        <ColorText />
      </div>
    </Color>
  );
}
Channel sliders and a text field