{"_id":"@daformat/contrast-color","name":"@daformat/contrast-color","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@daformat/contrast-color","version":"1.0.0","license":"Zero-Clause BSD","description":"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.","keywords":["contrast","contrast-color","accessibility","a11y","wcag","apca","oklch","oklab","color","colour","color-contrast","readable","luminance","srgb"],"homepage":"https://github.com/daformat/contrast-color","bugs":{"url":"https://github.com/daformat/contrast-color/issues"},"author":{"name":"Mathieu Jouhet"},"repository":{"type":"git","url":"git+https://github.com/daformat/contrast-color.git"},"sideEffects":false,"type":"module","main":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./package.json":"./package.json"},"devDependencies":{"@changesets/cli":"^2.30.0","@eslint/eslintrc":"^3.3.5","@eslint/js":"^10.0.1","@typescript-eslint/eslint-plugin":"^8.57.2","@typescript-eslint/parser":"^8.57.2","eslint":"^10.1.0","eslint-config-prettier":"^10.1.8","eslint-import-resolver-typescript":"^4.4.4","eslint-plugin-import":"^2.32.0","eslint-plugin-simple-import-sort":"^12.1.1","prettier":"^3.8.1","typescript":"^6.0.2","vitest":"^4.1.1"},"scripts":{"build":"tsc","ci":"pnpm build && pnpm typecheck && pnpm lint:js && pnpm format:check && pnpm test","format":"prettier --write .","format:check":"prettier --check .","lint:js":"eslint .","lint:js:fix":"eslint --fix .","local-release":"changeset version && changeset publish","test":"vitest run","typecheck":"tsc -p tsconfig.test.json"},"_id":"@daformat/contrast-color@1.0.0","_integrity":"sha512-n0a8cwW1c5MDv8wDFE23xXVtgAuKJr1D1aa+7qDs6EaDUxL3GCbcbtKpISEKEfK4d8QZhJf8xMN3ir7Qu+ZwTg==","_resolved":"/private/var/folders/41/0ttj_jpd7_jdpz2b7b6knhsc0000gn/T/e4bcd894bd73bfbb1aa275c68de76e67/daformat-contrast-color-1.0.0.tgz","_from":"file:daformat-contrast-color-1.0.0.tgz","_nodeVersion":"22.14.0","_npmVersion":"10.9.2","dist":{"integrity":"sha512-n0a8cwW1c5MDv8wDFE23xXVtgAuKJr1D1aa+7qDs6EaDUxL3GCbcbtKpISEKEfK4d8QZhJf8xMN3ir7Qu+ZwTg==","shasum":"8963b9256571c8063bd0eda9ace30467e0a19ce1","tarball":"https://registry.npmjs.org/@daformat/contrast-color/-/contrast-color-1.0.0.tgz","fileCount":31,"unpackedSize":78212,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCBx8tW3FYt8g36G7tEXPyudw/75CNAmOuRHMMuX9oNWgIhAORMNmBXYdgbVNRmNJzmH32GctMXplyJGVx8Ixq73f9W"}]},"_npmUser":{"name":"daformat","email":"mat.jouhet@gmail.com"},"directories":{},"maintainers":[{"name":"daformat","email":"mat.jouhet@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/contrast-color_1.0.0_1787054173601_0.9972094215207385"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-18T11:56:13.298Z","1.0.0":"2026-08-18T11:56:13.776Z","modified":"2026-08-18T11:56:14.108Z"},"maintainers":[{"name":"daformat","email":"mat.jouhet@gmail.com"}],"description":"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.","homepage":"https://github.com/daformat/contrast-color","keywords":["contrast","contrast-color","accessibility","a11y","wcag","apca","oklch","oklab","color","colour","color-contrast","readable","luminance","srgb"],"repository":{"type":"git","url":"git+https://github.com/daformat/contrast-color.git"},"author":{"name":"Mathieu Jouhet"},"bugs":{"url":"https://github.com/daformat/contrast-color/issues"},"license":"Zero-Clause BSD","readme":"# contrast-color\n\n![NPM Version](https://img.shields.io/npm/v/%40daformat%2Fcontrast-color)\n![gzipped](https://img.shields.io/bundlephobia/minzip/@daformat/contrast-color?color=%238ab4f8&label=gzip)\n![NPM Downloads](https://img.shields.io/npm/dm/%40daformat%2Fcontrast-color)  \n[![Follow daformat on GitHub](https://img.shields.io/github/followers/daformat?label=Follow%20%40daformat&style=social)](https://github.com/daformat)\n[![Follow daformat on X](https://img.shields.io/twitter/follow/daformat?label=Follow%20%40daformat&style=social)](https://twitter.com/daformat)\n\nBlack or white ink is the easy half of contrast, and CSS now answers it by itself with `contrast-color()`. The harder\nhalf is taking a colour you chose and finding the nearest readable version of _it_: same hue, same chroma, only the\nlightness moved. That is what this does.\n\nZero dependencies, no framework, pure functions over arrays of three numbers. It runs in Node, Deno, Bun and the\nbrowser, and it is cheap enough to call a few hundred times a frame while you drag a colour picker around.\n\n## Installation\n\n```bash\nnpm install @daformat/contrast-color\n```\n\n```bash\nyarn add @daformat/contrast-color\n```\n\n```bash\npnpm add @daformat/contrast-color\n```\n\n```bash\nbun add @daformat/contrast-color\n```\n\n```bash\ndeno add npm:@daformat/contrast-color\n```\n\n## Demo\n\nhttps://hello-mat.com/design-engineering/contrast-colors\n\n## Usage\n\n```ts\nimport { contrastShift } from \"@daformat/contrast-color\";\n\n// WCAG 2.1, body text on a blue background\ncontrastShift(\"#1e3a8a\", \"#3b82f6\", { target: 4.5 });\n// -> { hex: \"#041262\", score: 4.5, direction: \"darker\", reached: true, … }\n\n// APCA, same pair, Lc 60\ncontrastShift(\"#1e3a8a\", \"#3b82f6\", { target: 60, metric: \"apca\" });\n// -> { hex: \"#eaf1ff\", score: 60, direction: \"lighter\", reached: true, … }\n\n// Omit the target and each metric uses its own default: 4.5 and Lc 75\ncontrastShift(\"#777777\", \"#ffffff\");\ncontrastShift(\"#777777\", \"#ffffff\", { metric: \"apca\" });\n```\n\n`reached: false` means no colour at this hue clears the target, in either direction. What comes back is the best\navailable rather than an error, so check it before you ship it:\n\n```ts\nconst result = contrastShift(fg, bg, { target: 75, metric: \"apca\" });\nif (!result.reached) {\n  console.warn(\"unreachable, best is\", result.score);\n}\n```\n\nColours go in as hex strings (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, hash optional) or as channels in `0..1`, and\nevery result carries both forms back out:\n\n```ts\nimport { contrastShift, rgb } from \"@daformat/contrast-color\";\n\ncontrastShift(rgb(\"#1e3a8a\"), [0.23, 0.51, 0.96], { target: 4.5 });\n```\n\nHex is the only string format. Anything else **throws** rather than guessing — see\n[other colour formats](#other-colour-formats) for the two-line conversion.\n\n## How it works\n\nThe colour is converted to OKLCh, and only `L` moves. Hue is never touched, so the result still looks like the colour\nyou picked. Chroma is kept where the sRGB gamut allows it and fitted to the gamut boundary where it does not.\n\nThe score is monotonic in `L` on either side of the background, so each direction can be bisected. That is the whole\nalgorithm: no heuristics, no step sizes to tune. Both directions are searched, and the one that clears the bar with the\nsmaller move wins — the smallest change to your colour that makes it readable.\n\n## API\n\n### `contrastShift(color, background, options?)`\n\nThe main entry point. Returns a `ShiftResult`.\n\n| Option           | Type               | Default            | What it does                                                                 |\n| ---------------- | ------------------ | ------------------ | ---------------------------------------------------------------------------- |\n| `target`         | `number`           | the metric's `def` | The score to clear. `4.5` for WCAG, `75` for APCA.                           |\n| `metric`         | `\"wcag\" \\| \"apca\"` | `\"wcag\"`           | Which metric to score against.                                               |\n| `preserveChroma` | `boolean`          | `true`             | `false` trades chroma away in proportion to how far the lightness travelled. |\n\n`preserveChroma: false` gives a softer, less saturated result at the same score. It does not make an unreachable target\nreachable — nothing does, at a fixed hue — but when the target is out of reach it often lands on a slightly better best\neffort.\n\n```ts\ntype ShiftResult = {\n  /** The shifted colour, as channels in 0..1. Opaque; its alpha is `alpha`. */\n  rgb: Rgb;\n  /** The same, as `#rrggbb`, or `#rrggbbaa` when `alpha` is below 1. */\n  hex: string;\n  /** The alpha carried through from the input, untouched. */\n  alpha: number;\n  /** What you actually see: `rgb` at `alpha` over the background. */\n  composite: Rgb;\n  compositeHex: string;\n  /** Unsigned, in the units of `metric`: a ratio for WCAG, Lc for APCA. */\n  score: number;\n  /** The OKLCh lightness landed on, and the one started from. */\n  L: number;\n  L0: number;\n  /** The chroma and hue of the input, in OKLCh. Hue is in radians. */\n  C0: number;\n  H: number;\n  direction: \"none\" | \"lighter\" | \"darker\";\n  /** false means nothing at this hue clears the target: this is a best effort. */\n  reached: boolean;\n  metric: MetricId;\n};\n```\n\n### Alpha\n\nA translucent foreground is supported: the alpha is held fixed, the lightness moves underneath it, and every score is\nmeasured on the **composite** — the pixel you would actually see over the background.\n\n```ts\ncontrastShift(\"#3b82f6cc\", \"#ffffff\", { target: 4.5 });\n// -> { hex: \"#0654c4cc\", composite: […], compositeHex: \"#3776d0\", score: 4.5, reached: true }\n```\n\nTwo limitations, both deliberate:\n\n**The background must be opaque.** Scoring a translucent background would mean knowing what is behind it, and this\nlibrary does not take a backdrop. Composite it yourself first:\n\n```ts\nimport { composite, contrastShift, rgb, rgba } from \"@daformat/contrast-color\";\n\nconst onPage = composite(rgba(\"#ffffff80\"), rgb(\"#f5f5f5\"));\ncontrastShift(\"#3b82f6\", onPage, { target: 4.5 });\n```\n\n**Alpha is a hard ceiling on how far the colour can travel.** At `alpha: 0.5` the composite only gets halfway from the\nbackground toward black or white, so a lot of targets simply cannot be met at any lightness. Over white:\n\n| alpha | Best possible WCAG ratio |\n| ----- | ------------------------ |\n| 1     | 21:1                     |\n| 0.8   | 12.63:1                  |\n| 0.6   | 5.74:1                   |\n| 0.5   | 3.98:1                   |\n| 0.3   | 2.11:1                   |\n\nSo 4.5:1 is out of reach below roughly `alpha: 0.55`, whatever colour you start from. `reached: false` is common with\nalpha, and the result is a best effort that has usually collapsed to black or white. Check it.\n\nEverything else in the library — `contrastRatio`, `apcaLc`, `readableInk` — takes **opaque** colours only. Run\ntranslucent ones through `composite` first.\n\n### Other colour formats\n\nHex only, on purpose: a CSS colour parser is bigger than the rest of this library and has no natural stopping point.\nConvert first — the `Rgb`/`Rgba` input path means any parser drops straight in.\n\nIn a browser the platform already has one, and painting a pixel is the only way to get sRGB numbers back out of it\nuniformly. `ctx.fillStyle` alone is not enough: it echoes `oklch()` and `color-mix()` back at you unchanged, and\nsilently keeps its previous value when the colour is invalid.\n\n```ts\nconst canvas = document.createElement(\"canvas\");\ncanvas.width = canvas.height = 1;\nconst ctx = canvas.getContext(\"2d\", { willReadFrequently: true })!;\n\n/** Any CSS colour the browser understands -> channels in 0..1, or null. */\nconst cssToRgba = (css: string): Rgba | null => {\n  ctx.fillStyle = \"#000\";\n  ctx.fillStyle = css;\n  const first = ctx.fillStyle;\n  ctx.fillStyle = \"#fff\";\n  ctx.fillStyle = css;\n  if (ctx.fillStyle !== first) {\n    return null; // the browser ignored the assignment: not a colour\n  }\n  ctx.clearRect(0, 0, 1, 1);\n  ctx.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;\n  return [r / 255, g / 255, b / 255, a / 255];\n};\n\ncontrastShift(cssToRgba(\"oklch(0.7 0.1 200)\")!, \"#ffffff\");\n```\n\nThat handles named colours, `hsl()`, `lab()`, `oklch()`, `color-mix()` and `color(display-p3 …)`, gamut-mapping wide\ncolours into sRGB on the way. Alpha survives, give or take a `1/255` rounding through the pixel buffer.\n\nAnywhere else, use a colour library and hand over channels directly:\n\n```ts\nimport { converter } from \"culori\";\n\nconst toRgb = converter(\"rgb\");\nconst { r, g, b } = toRgb(\"oklch(0.7 0.1 200)\");\ncontrastShift([r, g, b], \"#ffffff\");\n```\n\n### Black-or-white ink\n\n```ts\nimport {\n  INK_CROSSOVER,\n  readableInk,\n  rgb,\n  yiqInk,\n} from \"@daformat/contrast-color\";\n\nreadableInk(rgb(\"#fde047\")); // \"#000000\"\n```\n\n- **`readableInk(color)`** — black or white, whichever contrasts more. White wins ties. This is what CSS\n  `contrast-color()` returns, bit for bit.\n- **`INK_CROSSOVER`** — the luminance where black and white contrast equally: `sqrt(0.0525) - 0.05`, or\n  `0.1791287847…`. Use the exact value rather than the rounded `0.179` you see quoted around: the rounded form disagrees\n  with the greater-of-black-or-white rule on about 0.03% of sRGB colours.\n- **`yiqInk(color)` / `yiqLuma(color)`** — the NTSC luma rule every \"is this colour dark?\" snippet on the internet uses.\n  Wrong on three counts, exported for comparison: it says white on `#767676`, where luminance says black.\n\n### Metrics\n\n```ts\nimport {\n  apcaLc,\n  contrastRatio,\n  dualScore,\n  luminance,\n  METRICS,\n  polarity,\n  rgb,\n} from \"@daformat/contrast-color\";\n\ncontrastRatio(rgb(\"#000\"), rgb(\"#fff\")); // 21\napcaLc(rgb(\"#000\"), rgb(\"#fff\")); // 106.04 — text first, background second\npolarity(rgb(\"#000\"), rgb(\"#fff\")); // \"dark on light\"\ndualScore(rgb(\"#000\"), rgb(\"#fff\")); // \"21.00:1 · Lc 106.0\"\n```\n\n- **`luminance(color)`** — WCAG 2.1 relative luminance.\n- **`contrastRatio(a, b)`** — the WCAG 2.1 ratio, 1 to 21. Symmetric, so the argument order does not matter.\n- **`apcaLc(text, background)`** — APCA lightness contrast (SA98G, `apca-w3` 0.1.9 constants). **Signed**: positive is\n  dark text on a light background, negative is light on dark. Argument order matters here.\n- **`apcaY(color)` / `APCA_CONSTANTS`** — APCA's own screen luminance and its coefficients. APCA uses a plain 2.4\n  exponent rather than sRGB's piecewise transfer, so `apcaY` and `luminance` are not interchangeable.\n- **`polarity(fg, bg)`** — `\"dark on light\"` or `\"light on dark\"`, from the sign APCA already computes.\n- **`dualScore(color, bg)`** — both numbers in one string, for a label.\n\n`METRICS` is the interface `contrastShift` scores through, and it is exported so you can drive a metric switcher off it:\n\n```ts\ntype Metric = {\n  id: MetricId;\n  label: string;\n  /** Unsigned, so \"higher is better\" holds for both metrics. */\n  score: (fg: Rgb, bg: Rgb) => number;\n  fmt: (value: number) => string;\n  /** Named thresholds, for the ticks on a slider. */\n  targets: [value: number, label: string][];\n  /** The target used when the caller does not pick one. */\n  def: number;\n};\n\nMETRICS.wcag.fmt(4.5); // \"4.50:1\"\nMETRICS.apca.fmt(75); // \"Lc 75.0\"\nMETRICS.apca.targets; // [[45, \"Lc 45 large or bold\"], [60, …], [75, …], [90, …]]\n```\n\n### Colour conversion\n\nEverything the shift is built on is exported, because the moment you have a colour picker you need it too.\n\n```ts\nimport {\n  clamp01,\n  fitChroma,\n  hexToRgb,\n  inGamut,\n  linearToSrgb,\n  oklabToRgb,\n  oklchToRgb,\n  rgb,\n  rgbToHex,\n  rgbToOklab,\n  rgbToOklch,\n  srgbToLinear,\n} from \"@daformat/contrast-color\";\n```\n\n- **`hexToRgba(hex)`** — `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`, hash optional, into channels and an alpha in `0..1`.\n  Missing alpha is `1`. Returns `null` rather than throwing, because the usual caller is an input field the user is\n  still halfway through typing.\n- **`hexToRgb(hex)`** — the same, dropping the alpha: the return type has nowhere to put it.\n- **`rgba(hex)` / `rgb(hex)`** — the same pair, for the places that must have a colour. **Throws a `TypeError`** on\n  anything they cannot parse. A silent fallback here becomes a black button nobody notices until it ships.\n- **`rgbToHex(color)`** — back to `#rrggbb`, clamping anything that drifted outside the range.\n- **`rgbaToHex(color)`** — to `#rrggbbaa`, or to `#rrggbb` when the colour is fully opaque.\n- **`composite(over, under)`** — lay a translucent colour over an opaque one and get what you would see. Simple alpha\n  compositing on gamma-encoded channels, which is what browsers paint, rather than in linear light.\n- **`rgbToOklch(color)` / `oklchToRgb(color)`** — sRGB to OKLCh and back. Hue is in **radians**, from `Math.atan2`.\n- **`rgbToOklab(color)` / `oklabToRgb(color)`** — the rectangular form, after Björn Ottosson. `oklabToRgb` is unclamped\n  on purpose: out-of-range channels are how `inGamut` does its job.\n- **`fitChroma(L, C, H)`** — the colour at this lightness and hue with the largest chroma `<= C` that stays inside sRGB.\n- **`inGamut(color)`** — whether every channel sits inside sRGB, give or take floating-point dust.\n- **`srgbToLinear(channel)` / `linearToSrgb(channel)`** — the sRGB transfer function and its inverse, per channel.\n- **`clamp01(value)`** — the `0..1` clamp everything else uses.\n\n### Types\n\n```ts\nimport type {\n  ColorInput,\n  Metric,\n  MetricId,\n  Oklab,\n  Oklch,\n  Rgb,\n  Rgba,\n  ShiftOptions,\n  ShiftResult,\n} from \"@daformat/contrast-color\";\n```\n\n| Type         | Shape                               | Notes                                            |\n| ------------ | ----------------------------------- | ------------------------------------------------ |\n| `Rgb`        | `[r: number, g: number, b: number]` | Gamma-encoded sRGB in `0..1`, **not** `0..255`.  |\n| `Oklab`      | `[L: number, a: number, b: number]` | Lightness in `0..1`, then the two opponent axes. |\n| `Oklch`      | `[L: number, C: number, H: number]` | Hue in radians.                                  |\n| `MetricId`   | `\"wcag\" \\| \"apca\"`                  |                                                  |\n| `ColorInput` | `string \\| Rgb`                     | What `contrastShift` accepts for either colour.  |\n\n## A note on CSS\n\nFor the black-or-white question, reach for the platform first:\n\n```css\n.card {\n  background: var(--bg);\n  color: contrast-color(var(--bg));\n}\n```\n\nIt costs nothing — no bundle, no main thread, decided in the style engine before paint — and it returns exactly what\n`readableInk` returns. This library is for the other question, the one CSS does not answer: keeping the colour and\nmoving it until it is readable.\n\n## License\n\n[Zero-Clause BSD](./LICENSE) © Mathieu Jouhet\n","readmeFilename":"README.md","_rev":"1-36c2a8fc1eda91e88da3b618bead266f"}