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).

ramp.ts
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 an apcaPreset ('body', 'large-text' or 'ui') to draw APCA regions instead; apcaPolarity and apcaRole choose 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.

250°
0.160
sRGB region (filled), the WCAG AA edge on white (solid) and on black (dashed), and one dot per ramp step
  1. W 18.07 AAB 1.16
  2. W 13.83 AAB 1.52
  3. W 9.67 AAB 2.17
  4. W 6.40 AAB 3.28
  5. W 4.28 B 4.90 AA
  6. W 2.93 B 7.17 AA
  7. W 2.05 B 10.22 AA
  8. W 1.50 B 14.03 AA
  9. W 1.12 B 18.70 AA
contrast-palette.ts
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),
  };
}