wcag-apca
v1.0.0
Published
WCAG 2 and APCA-W3 contrast in one dependency-free module, plus OKLab palette extraction and HEX/RGB/HSL/HSB/CMYK conversion.
Maintainers
Readme
wcag-apca
WCAG 2 and APCA-W3 contrast in one dependency-free module, plus OKLab palette extraction and colour conversion.
No dependencies. ESM. Works in Node and in the browser.
npm install wcag-apcaWhy both
WCAG 2 is the standard written into accessibility law. It compares relative luminance and ignores text size and weight entirely, so it fails combinations that read perfectly well at 24px and passes some that are genuinely hard to read at 14px.
APCA is the perceptual model drafted for WCAG 3. It accounts for size, weight and polarity — dark-on-light and light-on-dark are not symmetric to the human eye, and WCAG 2 treats them as if they were.
They disagree often enough that reporting only one of them is how unreadable colour combinations get signed off.
import { hexToRgb, wcag, wcagLevels, apca, apcaVerdict } from 'wcag-apca';
const fg = hexToRgb('#058F8F');
const bg = hexToRgb('#FFFFFF');
wcag(fg, bg); // 3.94 — fails WCAG AA for body text
wcagLevels(wcag(fg, bg)); // { normalAA: false, normalAAA: false, largeAA: true, ... }
apca(fg, bg); // 66.2
apcaVerdict(apca(fg, bg)); // { tier: 'Lc 60+', text: 'Larger text, 18px+ bold or 24px', ok: true }WCAG says no. APCA says yes, at 24px. Both are correct within their own model, and you probably want to know that before shipping the colour.
Verified against the reference values
APCA implementations drift easily — the constants are unforgiving and a wrong
exponent still returns plausible-looking numbers. npm test asserts the
published APCA-W3 0.1.9 reference values on every run:
| Case | Expected | This library |
|---|---|---|
| Black text on white | 106.04 | 106.0407 |
| White text on black | -107.88 | -107.8847 |
| #767676 on white (WCAG AA boundary) | 4.54:1 | 4.5422:1 |
API
Contrast
wcag(fg, bg)→ contrast ratio, 1–21. Both arguments[r, g, b], 0–255.wcagLevels(ratio)→{ normalAA, normalAAA, largeAA, largeAAA, ui }apca(fg, bg)→ Lc, roughly −108 to 106. Sign carries polarity: positive is dark text on a light background.apcaVerdict(lc)→{ tier, text, ok }, e.g.{ tier: 'Lc 75+', text: 'Body text at 16px+', ok: true }luminance(r, g, b)→ WCAG relative luminance
APCA is not symmetric: apca(a, b) is not -apca(b, a). Pass foreground
first and background second, or the result is wrong rather than merely negated.
Palettes
palette(data, want = 8)→ up towantrepresentative[r, g, b]colours from anRGBApixel array such asctx.getImageData(...).data
Quantisation runs in OKLab, a perceptually uniform space. Median cut in raw RGB collapses visually distinct blues into one swatch, because RGB distance and perceived difference are not the same thing. Each box is represented by its most populous member rather than its average — an average is frequently a colour that appears nowhere in the image.
oklab(r, g, b)→[L, a, b]
Conversion
hex(r, g, b) · hexToRgb(h) · rgbToHsl(r, g, b) · rgbToHsb(r, g, b) ·
hsbToRgb(h, s, v) · rgbToCmyk(r, g, b)
hexToRgb accepts #abc, #aabbcc, and either with or without the hash.
Try it without installing anything
The same code runs the contrast checker at
imgcolorpicker.org/contrast-checker,
where you can paste two colours and see both scores side by side, and the
image colour picker, which pulls palettes out of
an image using the palette() function above.
Licence
MIT © Tech Interval LLC
