Concepts
Contrast
WCAG and APCA measured on unrounded, displayed channels, and contrast regions that agree with those measurements point for point.
Two metrics
contrastRatio is WCAG 2.1: symmetric, from 1 to
21, with meetsAA at 4.5:1 (3:1 for large text) and
meetsAAA at 7:1.
contrastAPCA is APCA-W3 0.0.98G-4g: text first,
background second, positive for dark on light and negative for light on
dark. It returns Lc normalized to about -1.08 to 1.06; multiply by 100
for the conventional scale.
import { contrastAPCA, contrastRatio, meetsAA, parse } from '@pbroom/color-kit';
const gray = parse('#777');
const white = parse('#fff');
contrastRatio(gray, white); // → ≈ 4.478
meetsAA(gray, white); // → false
contrastAPCA(gray, white); // → ≈ 0.7111 (Lc 71.1)Unrounded by default
Measurements use the color's exact channels, so the result is continuous in
the color and #777 misses AA at 4.478 instead of rounding up. Hex-based
tools measure 8-bit values; pass { precision: '8bit' } to match them. Near
a threshold the two can disagree, so measure the value you ship.
import { contrastRatio, parse, toCss } from '@pbroom/color-kit';
const blue = parse('oklch(0.56725 0.12 250)');
const white = parse('#fff');
contrastRatio(blue, white); // → ≈ 4.4996, fails AA
contrastRatio(blue, white, { precision: '8bit' }); // → ≈ 4.5055, passes
toCss(blue, 'hex'); // → '#387aba'Measured as displayed
A color outside the display gamut is measured the way the display renders it
by clipping: channels are clipped to the gamut option ('srgb' by default,
or 'display-p3'). If you paint a gamut-mapped color, measure that one.
import { contrastRatio, parse, toSrgbGamut } from '@pbroom/color-kit';
const green = parse('oklch(0.75 0.22 150)'); // outside sRGB
const black = parse('#000');
contrastRatio(green, black); // → ≈ 10.37 (clipped to sRGB)
contrastRatio(green, black, { gamut: 'display-p3' }); // → ≈ 10.26
contrastRatio(toSrgbGamut(green), black); // → ≈ 10.21 (what you paint)Regions agree with checks
contrastRegionPaths, and the plane's
contrastRegion query, return the edge of the area at one hue that meets a
level against a reference. A point inside passes contrastRatio or
contrastAPCA with the same gamut. This is exact, not approximate, because of
how the solver works:
- Both metrics see a sample only through one displayed luminance, computed by the same code the checks use. Each side of the region is one level curve of that luminance.
- Along a ray from black at a fixed hue, every linear channel scales by the same cube, so displayed luminance never decreases inside the gamut. Each side crosses a ray at most once, and a bracketed solve finds the crossing.
- Where a curve meets the gamut edge, the chroma axis or
maxChromais solved on a fixed internal scan, to about1e-14. The sampling options (initialSamples,errorTolerance,maxDepth) only set how finely the curve between those ends is drawn; they cannot merge, split or drop pieces.
Every returned point passes the public check and is in gamut per
inSrgbGamut or inP3Gamut. Traces report the solver as 'contrast-rays'.
Try it
The contour is re-checked here with the public functions. Each sample is a
contour point painted on the reference, labelled with its contrast measured
before rounding (the default 'exact' precision). Rounded to hex, a point
this close to the edge can land on either side of the threshold; see
Unrounded by default.
- measured
- before rounding to 8 bits
- contour points
- 57
- pass the check
- 57 of 57
import { useState } from 'react';
import {
contrastAPCA,
contrastRatio,
contrastRegionPaths,
inSrgbGamut,
parse,
toCss,
type Color,
} from '@pbroom/color-kit';
import { Range, Readout, Toggle } from './controls';
const REFERENCES = { white: parse('#ffffff'), black: parse('#000000') };
type Reference = keyof typeof REFERENCES;
const METRICS = ['wcag', 'apca'] as const;
type Metric = (typeof METRICS)[number];
export default function ContrastAgreement() {
const [hue, setHue] = useState(250);
const [ref, setRef] = useState<Reference>('white');
const [metric, setMetric] = useState<Metric>('wcag');
const reference = REFERENCES[ref];
// WCAG AA is 4.5:1; APCA "body" is Lc 60, normalized to 0.6.
const paths = contrastRegionPaths(reference, hue, {
metric,
...(metric === 'wcag' ? { level: 'AA' } : { apcaPreset: 'body' }),
});
// Unrounded on purpose: the contour is exact, so check it before any
// rounding to 8 bits (the default `precision: 'exact'`).
const measure = (color: Color) =>
metric === 'wcag'
? contrastRatio(color, reference)
: Math.abs(contrastAPCA(color, reference));
const threshold = metric === 'wcag' ? 4.5 : 0.6;
// Re-check every contour point with the public functions.
const points = paths.flat().map(({ l, c }) => ({ l, c, h: hue, alpha: 1 }));
const passing = points.filter(
(color) => measure(color) >= threshold && inSrgbGamut(color),
);
const samples = points.filter(
(_, i) => i % Math.ceil(points.length / 5) === 0,
);
return (
<div className="grid gap-4">
<Range
label="Hue"
value={hue}
min={0}
max={359}
step={1}
format={(v) => `${v}°`}
onChange={setHue}
/>
<div className="flex flex-wrap gap-3">
<Toggle
label="Reference"
options={['white', 'black'] as const}
value={ref}
onChange={setRef}
/>
<Toggle
label="Metric"
options={METRICS}
value={metric}
onChange={setMetric}
/>
</div>
<div
className="flex flex-wrap gap-4 rounded-sm border border-border p-3"
style={{ background: toCss(reference, 'hex') }}
>
{samples.map((color) => (
<span
key={`${color.l}-${color.c}`}
className="font-[600]"
style={{ color: toCss(color, 'oklch') }}
>
Aa {measure(color).toFixed(3)}
</span>
))}
</div>
<Readout
rows={[
['measured', 'before rounding to 8 bits'],
['contour points', String(points.length)],
['pass the check', `${passing.length} of ${points.length}`],
]}
/>
</div>
);
}