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.
- contrast_ratio (foreground: string, background: string) -> float
- readable_text_color (background: string, candidates: string[]) -> string
Once installed, your code imports each one from the group's module.
contrast_ratio throws on bad input 13 tests
pub fn contrast_ratio(foreground: &str, background: &str) -> f64
| 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
contrast_ratio(#000000, #ffffff)→ 21 black on white is the maximum, 21contrast_ratio(#ffffff, #000000)→ 21 the order of the colours does not mattercontrast_ratio(#ffffff, #FFF)→ 1 a colour against itself is 1
fune!(charts.contrast@^1); // then call contrast_ratio(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_color_parse_hex::parse_hex;
use super::charts_color_srgb_to_linear::srgb_to_linear;
use super::math_round_float::round_float; ← 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.
pub fn relative_luminance(hex: &str) -> f64 {
let c = parse_hex(hex);
0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
}
/// (lighter + 0.05) / (darker + 0.05), unrounded.
pub fn luminance_contrast(a: &str, b: &str) -> f64 {
let la = relative_luminance(a);
let lb = relative_luminance(b);
if la > lb {
(la + 0.05) / (lb + 0.05)
} else {
(lb + 0.05) / (la + 0.05)
}
}
/// The WCAG 2.x contrast ratio, 1 to 21, rounded to 6 places.
///
/// # Panics
/// Panics if either colour is not a hex colour.
pub fn contrast_ratio(foreground: &str, background: &str) -> f64 {
round_float(luminance_contrast(foreground, background), 6)
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Float(contrast_ratio(args[0].as_str(), args[1].as_str()))
}readable_text_color throws on bad input 9 tests
pub fn readable_text_color(background: &str, candidates: &[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
readable_text_color(#0072b2, #000000, #ffffff)→ #ffffff white text on dark bluereadable_text_color(#e69f00, #000000, #ffffff)→ #000000 black text on orangereadable_text_color(#808080, #ffffff, #000000)→ #000000 black on mid grey #808080, which a 50% lightness rule gets wrong
fune!(charts.contrast@^1); // then call readable_text_color(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_contrast_contrast_ratio::luminance_contrast; ← contrastRatio, another function of this group · built into the same file, even by a slim install
/// The candidate that contrasts most with the background; ties go to the earlier one.
///
/// # Panics
/// Panics if there are no candidates or a colour is not a hex colour.
pub fn readable_text_color(background: &str, candidates: &[String]) -> String {
if candidates.is_empty() {
panic!("candidates must not be empty");
}
let mut best = &candidates[0];
let mut best_ratio = luminance_contrast(best, background);
for candidate in &candidates[1..] {
let ratio = luminance_contrast(candidate, background);
if ratio > best_ratio {
best = candidate;
best_ratio = ratio;
}
}
best.clone()
}
pub fn fune_vector(args: &[Value]) -> Value {
let candidates: Vec<String> = args[1].as_arr().iter().map(|v| v.as_str().to_string()).collect();
Value::str(&readable_text_color(args[0].as_str(), &candidates))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./charts.contrast-1.0.0-rust.fune, or fetch it from a terminal with fune pull charts.contrast@1.0.0:rust.
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).