Guides
Accessible, contrast-aware palettes
Build lightness ramps whose contrast you can predict, and use contrast regions to see which steps work as text on which background.
Ramp in lightness, map by chroma
Contrast is set by luminance, and in OKLCH luminance closely tracks
lightness. A ramp that varies only l at a fixed hue and chroma therefore
orders its steps by contrast. lightnessScale builds one,
copying chroma, hue and alpha from the base color.
The extremes of such a ramp leave the gamut: a near-white blue cannot keep
chroma 0.16. Map every step with toSrgbGamut.
Its default method lowers chroma only, so each step keeps the lightness, and
roughly the contrast, the ramp gave it. The 'css' method would move
lightness and break that ordering (see
Requested vs displayed).
import {
contrastRatio,
lightnessScale,
parse,
toSrgbGamut,
} from '@pbroom/color-kit';
const white = parse('#fff');
const ramp = lightnessScale(parse('oklch(0.6 0.15 250)'), 5, [0.2, 0.9]).map(
(step) => toSrgbGamut(step),
);
// Measured before rounding to hex (the default 'exact' precision).
ramp.map((step) => contrastRatio(step, white).toFixed(2));
// → ['18.11', '10.23', '4.85', '2.43', '1.34']Read pass/fail off the plane
Checking each step with contrastRatio tells you
which steps pass. A contrast region tells you why, and where the edge is
for every chroma at once: the plane's contrastRegion query returns the
contour of the area that meets a level against a reference.
Against white, everything darker than the contour passes; against black,
everything lighter. A step inside a region passes the matching check exactly,
because the region solver and contrastRatio measure the same displayed,
unrounded luminance (see Contrast).
Some consequences you can see in the example below:
- Text on both. AA against white needs relative luminance at most 0.1833; AA against black needs at least 0.175. The two edges nearly touch, and most ramps have no step between them.
- Mapped steps sit on the gamut edge. Where the ramp's chroma does not fit, the step lands on the sRGB boundary at its own lightness instead of at the ramp's chroma.
- APCA. Pass
metric: 'apca'with anapcaPreset('body','large-text'or'ui') to draw APCA regions instead;apcaPolarityandapcaRolechoose the direction.
In React, useContrastRegion returns the
same region as SVG path data for a picker's plane, so it can show the
passing area live while you drag.
The palette
Nine steps at one hue and chroma. Each dot is a step on the hue's
lightness-by-chroma plane. The solid line is the AA edge against white, the
dashed line the edge against black. Below, W and B are each step's ratio
against white and black, measured on the hex its swatch paints
({ precision: '8bit' }) so the AA label matches what is on screen. The
edges are unrounded, so a step within rounding distance of one can read on
the other side of 4.5.
- W 18.07 AAB 1.16
- W 13.83 AAB 1.52
- W 9.67 AAB 2.17
- W 6.40 AAB 3.28
- W 4.28 B 4.90 AA
- W 2.93 B 7.17 AA
- W 2.05 B 10.22 AA
- W 1.50 B 14.03 AA
- W 1.12 B 18.70 AA
import {
contrastRatio,
lightnessScale,
parse,
toSrgbGamut,
type Color,
} from '@pbroom/color-kit';
import {
colorToPlane,
definePlane,
sense,
toSvgCompoundPath,
} from '@pbroom/color-kit/plane';
const WHITE = parse('#ffffff');
const BLACK = parse('#000000');
export interface Step {
color: Color;
/**
* WCAG ratios against white and black text backgrounds, measured on the
* 8-bit hex the swatch paints so a label agrees with what is on screen.
*/
onWhite: number;
onBlack: number;
/** Position on the hue's lightness × chroma plane, 0–100. */
x: number;
y: number;
}
/**
* A lightness ramp at one hue and chroma, mapped into sRGB, with the
* regions that explain which steps pass WCAG AA on white and on black.
*/
export function contrastPalette(hue: number, chroma: number, steps = 9) {
const plane = definePlane({ fixed: { h: hue } });
const query = sense(plane);
// Chroma reduction keeps lightness, so a step's contrast is set by its
// place in the ramp even where the gamut cuts chroma.
const ramp = lightnessScale(
{ l: 0.5, c: chroma, h: hue, alpha: 1 },
steps,
[0.2, 0.96],
).map((color) => toSrgbGamut(color));
const palette: Step[] = ramp.map((color) => {
const { x, y } = colorToPlane(plane, color);
return {
color,
onWhite: contrastRatio(color, WHITE, { precision: '8bit' }),
onBlack: contrastRatio(color, BLACK, { precision: '8bit' }),
x: x * 100,
y: y * 100,
};
});
const svg = (paths: { x: number; y: number }[][], closeLoop = false) =>
toSvgCompoundPath(paths, { closeLoop, precision: 2 });
return {
palette,
gamut: svg(query.gamutRegion({ gamut: 'srgb' }).visibleRegion.paths, true),
// Everything darker than this edge passes AA as text on white…
onWhite: svg(query.contrastRegion({ reference: WHITE, level: 'AA' }).paths),
// …and everything lighter than this one passes on black.
onBlack: svg(query.contrastRegion({ reference: BLACK, level: 'AA' }).paths),
};
}