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.
Colorprovides shared state to everything inside it;useColorContextreads it.useColorPlaneRendererpaints a two-channel OKLCH plane (lightness by chroma by default) into your<canvas>, with WebGL and a CPU fallback.useGamutBoundary,useChromaBand,useContrastRegionanduseFallbackPointsreturn overlay geometry as points and SVG path data for the same plane.useAdaptiveQualitytrades resolution for frame rate while a drag is slow.- The driver turns input into colors:
createPointerDragControllerandcolorFromColorAreaPositionfor pointers,colorFromColorAreaKeyfor the keyboard, andgetColorAreaValueTextfor screen readers.
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.
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
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>
);
}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.
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.
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>
);
}