Functional Weave
Code in TypeScript

charts.contrast

WCAG 2.x contrast ratio between two colours, and which text colour reads best on a background.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 22 tests, run in TypeScript, Python and Rust.contrastRatio 13 · readableTextColor 9

What it does

`contrastRatio` is the WCAG 2.x contrast ratio between two colours, from 1 (identical) to 21 (black on white). `readableTextColor` picks, from the candidates you offer (usually black and white), the one that contrasts most with a background: the label colour for a bar, a pie slice or a heatmap cell.

## The formula

The functions

A group: 2 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. contrastRatio (foreground: string, background: string) -> float
  2. readableTextColor (background: string, candidates: string[]) -> string

Once installed, your code imports each one from the group's module.

contrastRatio throws on bad input 13 tests

export function contrastRatio(foreground: string, background: string): number
foregroundstringhex colour; the order of the two does not matter
backgroundstringhex colour
returnsfloat1 to 21, rounded to 6 decimal places; WCAG AA needs 4.5 for body text

For example

  • contrastRatio(#000000, #ffffff) → 21 black on white is the maximum, 21
  • contrastRatio(#ffffff, #000000) → 21 the order of the colours does not matter
  • contrastRatio(#ffffff, #FFF) → 1 a colour against itself is 1
import { contrastRatio } from "#fune/charts.contrast@^1";
impl/typescript/contrast_ratio.ts · 26 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { parseHex } from "./charts_color_parse_hex.ts";
import { srgbToLinear } from "./charts_color_srgb_to_linear.ts";
import { roundFloat } from "./math_round_float.ts";  ← from math.round-float ^1.0.0 · built alongside by fune

/** WCAG 2.x relative luminance: linear-light R, G, B weighted 0.2126, 0.7152, 0.0722. */
export function relativeLuminance(hex: string): number {
  const c = parseHex(hex);
  return 0.2126 * srgbToLinear(c.r) + 0.7152 * srgbToLinear(c.g) + 0.0722 * srgbToLinear(c.b);
}

/** (lighter + 0.05) / (darker + 0.05), unrounded, for readableTextColor to compare. */
export function luminanceContrast(a: string, b: string): number {
  const la = relativeLuminance(a);
  const lb = relativeLuminance(b);
  return la > lb ? (la + 0.05) / (lb + 0.05) : (lb + 0.05) / (la + 0.05);
}

/**
 * The WCAG 2.x contrast ratio, from 1 (identical) to 21 (black on white).
 * Luminance comes from linear light, not the stored channel numbers: pure red
 * on white is 4.0:1 and mid grey #808080 is 5.3:1 against black but only
 * 3.9:1 against white.
 */
export function contrastRatio(foreground: string, background: string): number {
  return roundFloat(luminanceContrast(foreground, background), 6);
}

readableTextColor throws on bad input 9 tests

export function readableTextColor(background: string, candidates: readonly string[]): string
backgroundstring
candidatesstring[]hex colours to choose from, usually ["#000000", "#ffffff"]
returnsstringthe candidate with the highest contrast, the first on a tie, exactly as given

For example

  • readableTextColor(#0072b2, #000000, #ffffff) → #ffffff white text on dark blue
  • readableTextColor(#e69f00, #000000, #ffffff) → #000000 black text on orange
  • readableTextColor(#808080, #ffffff, #000000) → #000000 black on mid grey #808080, which a 50% lightness rule gets wrong
import { readableTextColor } from "#fune/charts.contrast@^1";
impl/typescript/readable_text_color.ts · 20 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { luminanceContrast } from "./charts_contrast_contrast_ratio.ts";  ← contrastRatio, another function of this group · built into the same file, even by a slim install

/**
 * The candidate that contrasts most with the background: the label colour for
 * a bar or a heatmap cell. Ties go to the earlier candidate, so the caller's
 * order is the preference.
 */
export function readableTextColor(background: string, candidates: readonly string[]): string {
  if (candidates.length === 0) throw new RangeError("candidates must not be empty");
  let best = candidates[0];
  let bestRatio = luminanceContrast(best, background);
  for (let i = 1; i < candidates.length; i++) {
    const ratio = luminanceContrast(candidates[i], background);
    if (ratio > bestRatio) {
      best = candidates[i];
      bestRatio = ratio;
    }
  }
  return best;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add charts.contrast

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add charts.contrast --only contrastRatio
Download for TypeScript charts.contrast-1.0.0-typescript.fune · 10,152 bytes sha256 65307b92ec20836dd78e6eba567091d70c200e6856c857a52528258de7783e72

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./charts.contrast-1.0.0-typescript.fune, or fetch it from a terminal with fune pull charts.contrast@1.0.0:typescript.

The whole function, every language, is one file too: charts.contrast-1.0.0.fune, 13,998 bytes, sha256 8df0a1cc226f71b52191a6179462ce49d8e1b679809122fe20ee84efe740755b. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before charts.contrast.contrastRatio
// fune: before charts.contrast.readableTextColor

after — your function gets the result and the arguments, and returns the final result.

// fune: after charts.contrast.contrastRatio
// fune: after charts.contrast.readableTextColor

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace charts.color in charts.contrast
// fune: replace math.round-float in charts.contrast

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.contrast --steps.

// fune: step charts.contrast.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

contrastRatio 13 tests

CaseArgumentsExpected
black on white is the maximum, 21 #000000, #ffffff → 21
the order of the colours does not matter #ffffff, #000000 → 21
a colour against itself is 1 #ffffff, #FFF → 1
#777777 on white just fails AA at 4.48 #777777, #ffffff → 4.478
#767676 on white just passes AA at 4.54 #767676, #ffffff → 4.542
#595959 on white passes AAA at 7.0 #595959, #ffffff → 7.005
pure red on white is under 4, despite looking strong #ff0000, #ffffff → 3.998
mid grey against black #808080, #000000 → 5.317
mid grey against white is lower: luminance is linear light, not channel numbers #808080, #ffffff → 3.949
Okabe-Ito orange against black #e69f00, #000000 → 9.324
Show the other 3 tests
CaseArgumentsExpected
Okabe-Ito blue against white #0072b2, #ffffff → 5.185
a near-black on the straight part of the sRGB curve #0a0a0a, #000 → 1.061
a bad colour is an error #12345, #ffffff → error: is not a hex colour

readableTextColor 9 tests

CaseArgumentsExpected
white text on dark blue #0072b2, #000000, #ffffff → #ffffff
black text on orange #e69f00, #000000, #ffffff → #000000
black on mid grey #808080, which a 50% lightness rule gets wrong #808080, #ffffff, #000000 → #000000
black on vermillion (5.4 against 3.9 for white) #d55e00, #ffffff, #000000 → #000000
white on black #000, #000000, #ffffff → #ffffff
a tie goes to the first candidate, returned as given #ffffff, #000, #000000 → #000
one candidate is returned whatever its contrast #ffffff, #eeeeee → #eeeeee
three candidates, the dark grey wins on yellow #f0e442, #ffffff, #333333, #0072b2 → #333333
no candidates is an error #ffffff, → error: candidates must not be empty

More from the author

Relative luminance L = 0.2126 R + 0.7152 G + 0.0722 B, where R, G and B are the channels converted to linear light by the sRGB transfer function (`charts.color`'s `srgbToLinear`). The ratio is (L1 + 0.05) / (L2 + 0.05) with L1 the lighter of the two, so the order of the arguments does not matter.

WCAG 2.0's text gives the linearisation threshold as 0.03928; the sRGB standard, and WCAG 2.2's note, say 0.04045. No 8-bit channel lies between them (10/255 = 0.0392 and 11/255 = 0.0431), so for hex colours the two agree exactly.

Because luminance is linear light, intuition from the channel numbers misleads: pure red on white is only 4.0:1, and mid grey `#808080` contrasts more with black (5.3:1) than with white (3.9:1), so a "lightness below 50% means white text" rule picks the wrong colour.

## Rounding and thresholds

`contrastRatio` is rounded to 6 decimal places. WCAG says the ratio must not be rounded up to meet a threshold: `#777777` on white is 4.478089, which fails AA (4.5 for body text, 3 for large text; AAA needs 7 and 4.5). Compare the result directly; do not round it to one place first. `readableTextColor` compares unrounded ratios, and a tie goes to the earlier candidate, which is returned exactly as written.

Sources: W3C, Web Content Accessibility Guidelines (WCAG) 2.2, definitions of "contrast ratio" and "relative luminance", and Success Criteria 1.4.3 and 1.4.6 (https://www.w3.org/TR/WCAG22/); IEC 61966-2-1 (sRGB).

Files

PathBytes
README.md1,789
impl/python/contrast_ratio.py874
impl/python/readable_text_color.py630
impl/rust/contrast_ratio.rs1,094
impl/rust/readable_text_color.rs980
impl/typescript/contrast_ratio.ts1,156
impl/typescript/readable_text_color.ts751
vectors.json3,034