Concepts
OKLCH-canonical colors
Every color-kit value is one OKLCH object. Formats are only input and output; the math happens in one perceptual space.
One value type
A Color is a plain object, { l, c, h, alpha }:
l: lightness, from0(black) to1(white).c: chroma, from0(gray) upward. sRGB peaks around0.32and Display P3 around0.37; there is no hard upper bound.h: hue in degrees.alpha: opacity, from0to1.
Every function takes and returns this shape. Hex, rgb(), hsl(),
color(display-p3 …) and the rest are formats at the edges:
parse and the from* functions bring them in, and
toCss and the to* functions take them out. A Color is
not tied to a gamut, so it can describe a color no display can show. Mapping
it to one is a separate, explicit step (see
Requested vs displayed).
Why OKLCH
OKLCH is the polar form of OKLab, a perceptually uniform space: equal steps
in l, c or h look like roughly equal changes. Lightness becomes a
reliable axis for contrast, and hue holds still when chroma is reduced to fit
a gamut. HSL's lightness does not mean much: hsl(60 100% 50%) and
hsl(240 100% 50%) share 50% but sit at OKLCH lightness 0.968 and
0.452.
Hue normalization
Hues that color-kit produces are in [0, 360): from parse, the
conversions, adjustHue, harmony helpers and plane definitions. A hue you
write in an object literal is kept as written, so normalize it with
normalizeHue before comparing.
import { normalizeHue, parse, toCss } from '@pbroom/color-kit';
parse('oklch(0.7 0.15 -30)').h; // → 330
toCss({ l: 0.5, c: 0.1, h: -30, alpha: 1 }, 'oklch'); // → 'oklch(0.5 0.1 -30)'
normalizeHue(-30); // → 330Achromatic hue
A gray has no hue. At chroma at or below
ACHROMATIC_CHROMA_THRESHOLD
(0.000004, the CSS Color 4 epsilon) the hue is powerless:
isAchromatic returns true and conversions report
h: 0. Interpolation treats a powerless hue as missing, so a mix from gray
takes the other endpoint's hue instead of sweeping through red.
import { isAchromatic, mix, parse } from '@pbroom/color-kit';
const gray = parse('#808080'); // { l: 0.5999, c: 3.3e-16, h: 0, alpha: 1 }
isAchromatic(gray.c); // → true
mix(gray, parse('#3b82f6')).h; // → ≈ 259.81, the blue's huePicker state goes one step further and keeps the previous hue when a gray arrives, so raising chroma again resumes it. That rule lives in the driver; see hue preservation for grays.
Try it
Type any CSS Color 4 string. The swatch paints the color mapped into sRGB.
- l
- 0.6231
- c
- 0.1880
- h
- 259.81
- alpha
- 1.000
- toCss
- oklch(0.6231 0.188 259.81)
import { useState } from 'react';
import { isAchromatic, toCss, toSrgbGamut, tryParse } from '@pbroom/color-kit';
import { Chip, Readout, Toggle } from './controls';
const PRESETS = [
'#3b82f6',
'#808080',
'oklch(0.7 0.15 -30)',
'hsl(210 90% 55% / 0.5)',
'color(display-p3 0 1 0)',
] as const;
const fixed = (value: number, digits: number) => value.toFixed(digits);
export default function OklchInspector() {
const [input, setInput] = useState<string>(PRESETS[0]);
// tryParse returns null instead of throwing, which suits typed input.
const color = tryParse(input);
return (
<div className="grid gap-4">
<Toggle
label="Presets"
options={PRESETS}
value={input as (typeof PRESETS)[number]}
onChange={setInput}
/>
<input
aria-label="CSS color"
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
spellCheck={false}
className="w-full rounded-sm border border-border bg-transparent px-2 py-1 [font-family:var(--font-mono)] text-[13px] text-foreground"
/>
{color ? (
<div className="flex items-start gap-4">
<Chip
css={toCss(toSrgbGamut(color), 'oklch')}
label={`Swatch for ${input}`}
/>
<Readout
rows={[
['l', fixed(color.l, 4)],
['c', fixed(color.c, 4)],
[
'h',
isAchromatic(color.c)
? `${color.h} (powerless)`
: fixed(color.h, 2),
],
['alpha', fixed(color.alpha, 3)],
['toCss', toCss(color, 'oklch')],
]}
/>
</div>
) : (
<p className="m-0 text-[13px] text-muted-foreground">
Not a CSS Color 4 color: <code>tryParse</code> returned{' '}
<code>null</code>.
</p>
)}
</div>
);
}