npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

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.

| 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 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";

| 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