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.

metrics.ts
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.

precision.ts
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.

displayed.ts
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 maxChroma is solved on a fixed internal scan, to about 1e-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.

250°
Aa 4.500Aa 4.500Aa 4.500Aa 4.500Aa 4.500
measured
before rounding to 8 bits
contour points
57
pass the check
57 of 57
contrast-agreement.tsx
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>
  );
}