@daformat/contrast-color

中文文档
1.0.0发布于 2026/08/18Zero-Clause BSDTypeScriptES Module零依赖近 30 天下载 9853

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.

原始使用文档
文档目录14 节
  1. Installation
  2. Demo
  3. Usage
  4. How it works
  5. API
  6. contrastShift(color, background, options?)
  7. Alpha
  8. Other colour formats
  9. Black-or-white ink
  10. Metrics
  11. Colour conversion
  12. Types
  13. A note on CSS
  14. License

contrast-color

NPM Version gzipped NPM Downloads
Follow daformat on GitHub Follow daformat on X

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-color
yarn add @daformat/contrast-color
pnpm add @daformat/contrast-color
bun add @daformat/contrast-color
deno add npm:@daformat/contrast-color

Demo

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.

OptionTypeDefaultWhat it does
targetnumberthe metric's defThe score to clear. 4.5 for WCAG, 75 for APCA.
metric"wcag" | "apca""wcag"Which metric to score against.
preserveChromabooleantruefalse 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:

alphaBest possible WCAG ratio
121:1
0.812.63:1
0.65.74:1
0.53.98:1
0.32.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 CSS contrast-color() returns, bit for bit.
  • INK_CROSSOVER — the luminance where black and white contrast equally: sqrt(0.0525) - 0.05, or 0.1791287847…. Use the exact value rather than the rounded 0.179 you 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-w3 0.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, so apcaY and luminance are 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, #rrggbb or #rrggbbaa, hash optional, into channels and an alpha in 0..1. Missing alpha is 1. Returns null rather 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 a TypeError on 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 #rrggbb when 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, from Math.atan2.
  • rgbToOklab(color) / oklabToRgb(color) — the rectangular form, after Björn Ottosson. oklabToRgb is unclamped on purpose: out-of-range channels are how inGamut does its job.
  • fitChroma(L, C, H) — the colour at this lightness and hue with the largest chroma <= C that 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) — the 0..1 clamp everything else uses.

Types

import type {
  ColorInput,
  Metric,
  MetricId,
  Oklab,
  Oklch,
  Rgb,
  Rgba,
  ShiftOptions,
  ShiftResult,
} from "@daformat/contrast-color";
TypeShapeNotes
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"
ColorInputstring | RgbWhat 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

版本与发布记录

@daformat/contrast-color 共发布 1 个版本,首次发布于 2026/08/18;过去 12 个月发布了 1 个版本。

版本发布日期距今
1.0.0latest2026/08/182 个月前

关于 @daformat/contrast-color 的常见问题

@daformat/contrast-color 是什么?

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.

如何安装 @daformat/contrast-color?

在项目目录运行 npm install @daformat/contrast-color,或使用 pnpm add / yarn add / bun add。

@daformat/contrast-color 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@daformat/contrast-color 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@daformat/contrast-color 可以商用吗?

@daformat/contrast-color 使用 Zero-Clause BSD 许可。声明的许可为 Zero-Clause BSD,请阅读许可原文确认使用条件。以上为概括,不构成法律意见,请以许可原文为准。

@daformat/contrast-color 的下载量有多少?

根据 npm 官方下载统计,@daformat/contrast-color 在 2026/10/11 – 2026/11/09 这 30 天内被下载 9,853 次,日均约 328 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@daformat/contrast-color 还在维护吗?

最新版本 1.0.0 发布于 2026/08/18(2 个月前);至今共发布 1 个版本,其中过去 12 个月发布了 1 个;首次发布于 2026/08/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。