Concepts
Requested vs displayed
color-kit keeps the color you asked for apart from the color a display can show, so intent survives gamut mapping.
Two colors, one state
A user can pick a color no screen can show. Clamping it in place would move the picker thumb, lose the choice on the next edit, and make undo lie. color-kit keeps two values instead:
- requested: the OKLCH color that was asked for, never clamped.
- displayed: the requested color mapped into an output gamut, used only to paint.
createColorState in @pbroom/color-kit/driver
holds both, mapped into sRGB and Display P3 at once:
import { createColorState } from '@pbroom/color-kit/driver';
const state = createColorState({ l: 0.75, c: 0.22, h: 150, alpha: 1 });
state.displayed.srgb.c; // → ≈ 0.2065 (reduced to fit sRGB)
state.displayed.p3.c; // → 0.22 (fits P3 as requested)
state.meta.outOfGamut; // → { srgb: true, p3: false }Store, serialize and edit requested. Paint the displayed color for the
active gamut (getActiveDisplayedColor).
Switching an sRGB preview to P3 restores the chroma, because nothing was
thrown away.
Two mapping methods
toSrgbGamut and
toP3Gamut return colors already in gamut unchanged.
Otherwise method picks the algorithm:
'chroma-reduction'(default) bisects chroma at fixed lightness and hue. Both are preserved exactly, which keeps contrast regions, plane geometry and slider tracks in place when the gamut changes.'css'runs the CSS Color 4 algorithm that browsers and colorjs.io use. It stops as soon as clipping moves the color by less than one just-noticeable difference (0.02 ΔE OK) and returns the clipped color: more chroma, with small shifts in lightness and hue.
import { parse, toCss, toSrgbGamut } from '@pbroom/color-kit';
const coral = parse('oklch(0.7 0.25 30)');
toCss(toSrgbGamut(coral), 'oklch'); // → 'oklch(0.7 0.1915 30)'
toCss(toSrgbGamut(coral, { method: 'css' }), 'oklch'); // → 'oklch(0.6831 0.2062 30.18)'The driver records its choice in meta.gamutMapMethod; pass gamutMapMethod
to createColorState to change it.
Hue preservation for grays
Hue has to survive zero chroma, or a picker forgets it the moment a user drags to the gray axis.
- Mapping.
'chroma-reduction'never writesh. Even at the lightness endpoints, where the result snaps to black or white withc: 0, the hue is kept:toSrgbGamut({ l: 1, c: 0.2, h: 250, alpha: 1 })returnsh: 250.'css'converts its clipped color back to OKLCH, so a near-gray result can report another hue. - Incoming colors. Hex,
rgb()andhsl()grays convert toh: 0.setColorRequestedwithexplicitHue: falsekeeps the previous hue instead (resolveIncomingRequestedis the rule on its own). The React setterssetFromString,setFromRgb,setFromHslandsetFromHsvapply it for you.
import { parse } from '@pbroom/color-kit';
import { createColorState, setColorRequested } from '@pbroom/color-kit/driver';
const blue = createColorState(parse('#3b82f6'));
const gray = setColorRequested(blue, parse('#808080'), 'user', {
explicitHue: false,
});
gray.requested.h === blue.requested.h; // → trueTry it
Raise chroma past the sRGB edge, then past the P3 edge. With
chroma-reduction lightness and hue never move; with css they drift.
- requested
- oklch(0.72 0.3 150)
- displayed.srgb
- oklch(0.72 0.1983 150)
- displayed.p3
- oklch(0.72 0.2766 150)
- outOfGamut
- srgb: true, p3: true
import { useState } from 'react';
import { toCss, type Color, type GamutMapMethod } from '@pbroom/color-kit';
import { createColorState } from '@pbroom/color-kit/driver';
import { Chip, Range, Readout, Toggle } from './controls';
const METHODS = [
'chroma-reduction',
'css',
] as const satisfies readonly GamutMapMethod[];
const oklch = (color: Color) => toCss(color, 'oklch');
export default function RequestedDisplayed() {
const [chroma, setChroma] = useState(0.3);
const [hue, setHue] = useState(150);
const [method, setMethod] = useState<GamutMapMethod>('chroma-reduction');
// One state object holds the request and both displayed mappings.
const state = createColorState(
{ l: 0.72, c: chroma, h: hue, alpha: 1 },
{ gamutMapMethod: method },
);
const { requested, displayed, meta } = state;
return (
<div className="grid gap-4">
<Range
label="Chroma"
value={chroma}
min={0}
max={0.4}
step={0.005}
format={(v) => v.toFixed(3)}
onChange={setChroma}
/>
<Range
label="Hue"
value={hue}
min={0}
max={359}
step={1}
format={(v) => `${v}°`}
onChange={setHue}
/>
<Toggle
label="Gamut mapping method"
options={METHODS}
value={method}
onChange={setMethod}
/>
<div className="flex items-center gap-2">
<Chip css={toCss(displayed.srgb, 'hex')} label="Displayed in sRGB" />
<Chip
css={toCss(displayed.p3, 'display-p3')}
label="Displayed in Display P3"
/>
</div>
<Readout
rows={[
['requested', oklch(requested)],
['displayed.srgb', oklch(displayed.srgb)],
['displayed.p3', oklch(displayed.p3)],
[
'outOfGamut',
`srgb: ${meta.outOfGamut.srgb}, p3: ${meta.outOfGamut.p3}`,
],
]}
/>
</div>
);
}