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.
- contrastRatio (foreground: string, background: string) -> float
- 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
| foreground | string | hex colour; the order of the two does not matter |
| background | string | hex colour |
| returns | float | 1 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, 21contrastRatio(#ffffff, #000000)→ 21 the order of the colours does not mattercontrastRatio(#ffffff, #FFF)→ 1 a colour against itself is 1
import { contrastRatio } from "#fune/charts.contrast@^1";
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
| background | string | |
| candidates | string[] | hex colours to choose from, usually ["#000000", "#ffffff"] |
| returns | string | the candidate with the highest contrast, the first on a tie, exactly as given |
For example
readableTextColor(#0072b2, #000000, #ffffff)→ #ffffff white text on dark bluereadableTextColor(#e69f00, #000000, #ffffff)→ #000000 black text on orangereadableTextColor(#808080, #ffffff, #000000)→ #000000 black on mid grey #808080, which a 50% lightness rule gets wrong
import { readableTextColor } from "#fune/charts.contrast@^1";
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
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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).