@daformat/contrast-color
v1.0.0
Published
Move a colour to a readable version of itself: same hue, same chroma, only the OKLCh lightness moved, scored against WCAG 2.1 or APCA. No dependencies, no framework.
Maintainers
Readme
contrast-color
Black or white ink is the easy half of contrast, and CSS now answers it by itself with contrast-color(). The harder
half is taking a colour you chose and finding the nearest readable version of it: same hue, same chroma, only the
lightness moved. That is what this does.
Zero dependencies, no framework, pure functions over arrays of three numbers. It runs in Node, Deno, Bun and the browser, and it is cheap enough to call a few hundred times a frame while you drag a colour picker around.
Installation
npm install @daformat/contrast-coloryarn add @daformat/contrast-colorpnpm add @daformat/contrast-colorbun add @daformat/contrast-colordeno add npm:@daformat/contrast-colorDemo
https://hello-mat.com/design-engineering/contrast-colors
Usage
import { contrastShift } from "@daformat/contrast-color";
// WCAG 2.1, body text on a blue background
contrastShift("#1e3a8a", "#3b82f6", { target: 4.5 });
// -> { hex: "#041262", score: 4.5, direction: "darker", reached: true, … }
// APCA, same pair, Lc 60
contrastShift("#1e3a8a", "#3b82f6", { target: 60, metric: "apca" });
// -> { hex: "#eaf1ff", score: 60, direction: "lighter", reached: true, … }
// Omit the target and each metric uses its own default: 4.5 and Lc 75
contrastShift("#777777", "#ffffff");
contrastShift("#777777", "#ffffff", { metric: "apca" });reached: false means no colour at this hue clears the target, in either direction. What comes back is the best
available rather than an error, so check it before you ship it:
const result = contrastShift(fg, bg, { target: 75, metric: "apca" });
if (!result.reached) {
console.warn("unreachable, best is", result.score);
}Colours go in as hex strings (#rgb, #rgba, #rrggbb, #rrggbbaa, hash optional) or as channels in 0..1, and
every result carries both forms back out:
import { contrastShift, rgb } from "@daformat/contrast-color";
contrastShift(rgb("#1e3a8a"), [0.23, 0.51, 0.96], { target: 4.5 });Hex is the only string format. Anything else throws rather than guessing — see other colour formats for the two-line conversion.
How it works
The colour is converted to OKLCh, and only L moves. Hue is never touched, so the result still looks like the colour
you picked. Chroma is kept where the sRGB gamut allows it and fitted to the gamut boundary where it does not.
The score is monotonic in L on either side of the background, so each direction can be bisected. That is the whole
algorithm: no heuristics, no step sizes to tune. Both directions are searched, and the one that clears the bar with the
smaller move wins — the smallest change to your colour that makes it readable.
API
contrastShift(color, background, options?)
The main entry point. Returns a ShiftResult.
| Option | Type | Default | What it does |
| ---------------- | ------------------ | ------------------ | ---------------------------------------------------------------------------- |
| target | number | the metric's def | The score to clear. 4.5 for WCAG, 75 for APCA. |
| metric | "wcag" \| "apca" | "wcag" | Which metric to score against. |
| preserveChroma | boolean | true | false trades chroma away in proportion to how far the lightness travelled. |
preserveChroma: false gives a softer, less saturated result at the same score. It does not make an unreachable target
reachable — nothing does, at a fixed hue — but when the target is out of reach it often lands on a slightly better best
effort.
type ShiftResult = {
/** The shifted colour, as channels in 0..1. Opaque; its alpha is `alpha`. */
rgb: Rgb;
/** The same, as `#rrggbb`, or `#rrggbbaa` when `alpha` is below 1. */
hex: string;
/** The alpha carried through from the input, untouched. */
alpha: number;
/** What you actually see: `rgb` at `alpha` over the background. */
composite: Rgb;
compositeHex: string;
/** Unsigned, in the units of `metric`: a ratio for WCAG, Lc for APCA. */
score: number;
/** The OKLCh lightness landed on, and the one started from. */
L: number;
L0: number;
/** The chroma and hue of the input, in OKLCh. Hue is in radians. */
C0: number;
H: number;
direction: "none" | "lighter" | "darker";
/** false means nothing at this hue clears the target: this is a best effort. */
reached: boolean;
metric: MetricId;
};Alpha
A translucent foreground is supported: the alpha is held fixed, the lightness moves underneath it, and every score is measured on the composite — the pixel you would actually see over the background.
contrastShift("#3b82f6cc", "#ffffff", { target: 4.5 });
// -> { hex: "#0654c4cc", composite: […], compositeHex: "#3776d0", score: 4.5, reached: true }Two limitations, both deliberate:
The background must be opaque. Scoring a translucent background would mean knowing what is behind it, and this library does not take a backdrop. Composite it yourself first:
import { composite, contrastShift, rgb, rgba } from "@daformat/contrast-color";
const onPage = composite(rgba("#ffffff80"), rgb("#f5f5f5"));
contrastShift("#3b82f6", onPage, { target: 4.5 });Alpha is a hard ceiling on how far the colour can travel. At alpha: 0.5 the composite only gets halfway from the
background toward black or white, so a lot of targets simply cannot be met at any lightness. Over white:
| alpha | Best possible WCAG ratio | | ----- | ------------------------ | | 1 | 21:1 | | 0.8 | 12.63:1 | | 0.6 | 5.74:1 | | 0.5 | 3.98:1 | | 0.3 | 2.11:1 |
So 4.5:1 is out of reach below roughly alpha: 0.55, whatever colour you start from. reached: false is common with
alpha, and the result is a best effort that has usually collapsed to black or white. Check it.
Everything else in the library — contrastRatio, apcaLc, readableInk — takes opaque colours only. Run
translucent ones through composite first.
Other colour formats
Hex only, on purpose: a CSS colour parser is bigger than the rest of this library and has no natural stopping point.
Convert first — the Rgb/Rgba input path means any parser drops straight in.
In a browser the platform already has one, and painting a pixel is the only way to get sRGB numbers back out of it
uniformly. ctx.fillStyle alone is not enough: it echoes oklch() and color-mix() back at you unchanged, and
silently keeps its previous value when the colour is invalid.
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
/** Any CSS colour the browser understands -> channels in 0..1, or null. */
const cssToRgba = (css: string): Rgba | null => {
ctx.fillStyle = "#000";
ctx.fillStyle = css;
const first = ctx.fillStyle;
ctx.fillStyle = "#fff";
ctx.fillStyle = css;
if (ctx.fillStyle !== first) {
return null; // the browser ignored the assignment: not a colour
}
ctx.clearRect(0, 0, 1, 1);
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255, a / 255];
};
contrastShift(cssToRgba("oklch(0.7 0.1 200)")!, "#ffffff");That handles named colours, hsl(), lab(), oklch(), color-mix() and color(display-p3 …), gamut-mapping wide
colours into sRGB on the way. Alpha survives, give or take a 1/255 rounding through the pixel buffer.
Anywhere else, use a colour library and hand over channels directly:
import { converter } from "culori";
const toRgb = converter("rgb");
const { r, g, b } = toRgb("oklch(0.7 0.1 200)");
contrastShift([r, g, b], "#ffffff");Black-or-white ink
import {
INK_CROSSOVER,
readableInk,
rgb,
yiqInk,
} from "@daformat/contrast-color";
readableInk(rgb("#fde047")); // "#000000"readableInk(color)— black or white, whichever contrasts more. White wins ties. This is what CSScontrast-color()returns, bit for bit.INK_CROSSOVER— the luminance where black and white contrast equally:sqrt(0.0525) - 0.05, or0.1791287847…. Use the exact value rather than the rounded0.179you see quoted around: the rounded form disagrees with the greater-of-black-or-white rule on about 0.03% of sRGB colours.yiqInk(color)/yiqLuma(color)— the NTSC luma rule every "is this colour dark?" snippet on the internet uses. Wrong on three counts, exported for comparison: it says white on#767676, where luminance says black.
Metrics
import {
apcaLc,
contrastRatio,
dualScore,
luminance,
METRICS,
polarity,
rgb,
} from "@daformat/contrast-color";
contrastRatio(rgb("#000"), rgb("#fff")); // 21
apcaLc(rgb("#000"), rgb("#fff")); // 106.04 — text first, background second
polarity(rgb("#000"), rgb("#fff")); // "dark on light"
dualScore(rgb("#000"), rgb("#fff")); // "21.00:1 · Lc 106.0"luminance(color)— WCAG 2.1 relative luminance.contrastRatio(a, b)— the WCAG 2.1 ratio, 1 to 21. Symmetric, so the argument order does not matter.apcaLc(text, background)— APCA lightness contrast (SA98G,apca-w30.1.9 constants). Signed: positive is dark text on a light background, negative is light on dark. Argument order matters here.apcaY(color)/APCA_CONSTANTS— APCA's own screen luminance and its coefficients. APCA uses a plain 2.4 exponent rather than sRGB's piecewise transfer, soapcaYandluminanceare not interchangeable.polarity(fg, bg)—"dark on light"or"light on dark", from the sign APCA already computes.dualScore(color, bg)— both numbers in one string, for a label.
METRICS is the interface contrastShift scores through, and it is exported so you can drive a metric switcher off it:
type Metric = {
id: MetricId;
label: string;
/** Unsigned, so "higher is better" holds for both metrics. */
score: (fg: Rgb, bg: Rgb) => number;
fmt: (value: number) => string;
/** Named thresholds, for the ticks on a slider. */
targets: [value: number, label: string][];
/** The target used when the caller does not pick one. */
def: number;
};
METRICS.wcag.fmt(4.5); // "4.50:1"
METRICS.apca.fmt(75); // "Lc 75.0"
METRICS.apca.targets; // [[45, "Lc 45 large or bold"], [60, …], [75, …], [90, …]]Colour conversion
Everything the shift is built on is exported, because the moment you have a colour picker you need it too.
import {
clamp01,
fitChroma,
hexToRgb,
inGamut,
linearToSrgb,
oklabToRgb,
oklchToRgb,
rgb,
rgbToHex,
rgbToOklab,
rgbToOklch,
srgbToLinear,
} from "@daformat/contrast-color";hexToRgba(hex)—#rgb,#rgba,#rrggbbor#rrggbbaa, hash optional, into channels and an alpha in0..1. Missing alpha is1. Returnsnullrather than throwing, because the usual caller is an input field the user is still halfway through typing.hexToRgb(hex)— the same, dropping the alpha: the return type has nowhere to put it.rgba(hex)/rgb(hex)— the same pair, for the places that must have a colour. Throws aTypeErroron anything they cannot parse. A silent fallback here becomes a black button nobody notices until it ships.rgbToHex(color)— back to#rrggbb, clamping anything that drifted outside the range.rgbaToHex(color)— to#rrggbbaa, or to#rrggbbwhen the colour is fully opaque.composite(over, under)— lay a translucent colour over an opaque one and get what you would see. Simple alpha compositing on gamma-encoded channels, which is what browsers paint, rather than in linear light.rgbToOklch(color)/oklchToRgb(color)— sRGB to OKLCh and back. Hue is in radians, fromMath.atan2.rgbToOklab(color)/oklabToRgb(color)— the rectangular form, after Björn Ottosson.oklabToRgbis unclamped on purpose: out-of-range channels are howinGamutdoes its job.fitChroma(L, C, H)— the colour at this lightness and hue with the largest chroma<= Cthat stays inside sRGB.inGamut(color)— whether every channel sits inside sRGB, give or take floating-point dust.srgbToLinear(channel)/linearToSrgb(channel)— the sRGB transfer function and its inverse, per channel.clamp01(value)— the0..1clamp everything else uses.
Types
import type {
ColorInput,
Metric,
MetricId,
Oklab,
Oklch,
Rgb,
Rgba,
ShiftOptions,
ShiftResult,
} from "@daformat/contrast-color";| Type | Shape | Notes |
| ------------ | ----------------------------------- | ------------------------------------------------ |
| Rgb | [r: number, g: number, b: number] | Gamma-encoded sRGB in 0..1, not 0..255. |
| Oklab | [L: number, a: number, b: number] | Lightness in 0..1, then the two opponent axes. |
| Oklch | [L: number, C: number, H: number] | Hue in radians. |
| MetricId | "wcag" \| "apca" | |
| ColorInput | string \| Rgb | What contrastShift accepts for either colour. |
A note on CSS
For the black-or-white question, reach for the platform first:
.card {
background: var(--bg);
color: contrast-color(var(--bg));
}It costs nothing — no bundle, no main thread, decided in the style engine before paint — and it returns exactly what
readableInk returns. This library is for the other question, the one CSS does not answer: keeping the colour and
moving it until it is readable.
License
Zero-Clause BSD © Mathieu Jouhet
