Skip to Content
ftui-styleWCAG contrast

WCAG Contrast

The WCAG 2.1 success criteria require specific contrast ratios between text and its background. ftui-style ships the math and the threshold constants so every theme, preset, and user-chosen color combination can be audited against the standard.

The four thresholds

pub const WCAG_AA_NORMAL_TEXT: f64 = 4.5; pub const WCAG_AA_LARGE_TEXT: f64 = 3.0; pub const WCAG_AAA_NORMAL_TEXT: f64 = 7.0; pub const WCAG_AAA_LARGE_TEXT: f64 = 4.5;
LevelNormal textLarge text
AA≥ 4.5 : 1≥ 3.0 : 1
AAA≥ 7.0 : 1≥ 4.5 : 1

“Large text” in WCAG terms is 18 pt regular or 14 pt bold. In a terminal the boundary is less crisp; err on the side of “normal” unless you’re rendering a banner in a deliberately larger cell font.

Relative luminance — linearized sRGB

Every sRGB channel value has to be linearized before contrast math, because sRGB uses a non-linear transfer curve:

clinear={c12.92,c0.04045(c+0.0551.055)2.4,c>0.04045c_{\text{linear}} = \begin{cases} \dfrac{c}{12.92}, & c \le 0.04045 \\[0.4em] \left(\dfrac{c + 0.055}{1.055}\right)^{2.4}, & c > 0.04045 \end{cases}

Then the relative luminance (per ITU-R BT.709 weights) is:

L=0.2126R+0.7152G+0.0722BL = 0.2126\,R + 0.7152\,G + 0.0722\,B

where RR, GG, BB are the linearized channels in [0, 1]. Green dominates because human vision is most sensitive to it.

pub fn relative_luminance(rgb: Rgb) -> f64; pub fn relative_luminance_packed(color: PackedRgba) -> f64;

Contrast ratio

Given two colors, the contrast ratio is:

cr(c1,c2)=Llighter+0.05Ldarker+0.05\mathrm{cr}(c_1, c_2) = \dfrac{L_{\text{lighter}} + 0.05}{L_{\text{darker}} + 0.05}

The + 0.05 flare term is defined by WCAG to model a small amount of ambient light in the viewing environment. The ratio ranges from 1.0 (identical colors, no contrast) to 21.0 (black on white).

pub fn contrast_ratio(fg: Rgb, bg: Rgb) -> f64; pub fn contrast_ratio_packed(fg: PackedRgba, bg: PackedRgba) -> f64;

The level helpers

pub fn meets_wcag_aa(fg: Rgb, bg: Rgb) -> bool; // ≥ 4.5 pub fn meets_wcag_aa_packed(fg: PackedRgba, bg: PackedRgba) -> bool; pub fn meets_wcag_aa_large_text(fg: Rgb, bg: Rgb) -> bool; // ≥ 3.0 pub fn meets_wcag_aaa(fg: Rgb, bg: Rgb) -> bool; // ≥ 7.0

Each is a one-liner over contrast_ratio. The _packed variants skip the Rgb::from conversion.

best_text_color — pick from candidates

pub fn best_text_color(bg: Rgb, candidates: &[Rgb]) -> Rgb;

Given a background and a palette of candidates (e.g. your theme’s foregrounds), returns the one with the highest contrast. Useful for “paint text on an arbitrary color cell” workflows.

cell_text_pick.rs
use ftui_style::{best_text_color, Rgb}; let bg = Rgb::new(240, 240, 240); let candidates = [ Rgb::new(20, 20, 20), // near-black Rgb::new(80, 80, 80), // dark grey Rgb::new(200, 50, 50), // red ]; let fg = best_text_color(bg, &candidates); assert_eq!(fg, Rgb::new(20, 20, 20));

Worked example — reject an illegible pair

audit.rs
use ftui_style::{contrast_ratio, meets_wcag_aa, Rgb}; fn audit(pair_name: &str, fg: Rgb, bg: Rgb) { let cr = contrast_ratio(fg, bg); let ok = meets_wcag_aa(fg, bg); println!("{pair_name:>20}: ratio {cr:5.2} AA: {ok}"); } audit("black on white", Rgb::new(0, 0, 0), Rgb::new(255, 255, 255)); audit("grey on white", Rgb::new(180, 180, 180), Rgb::new(255, 255, 255)); audit("dim yellow on...",Rgb::new(220, 200, 100), Rgb::new(240, 240, 240)); // stdout: // black on white: ratio 21.00 AA: true // grey on white : ratio 1.86 AA: false // dim yellow on.: ratio 1.49 AA: false

Why the 0.05 flare term matters

Without the flare term, contrast between two very dark colors would go to infinity (L0L \to 0 in the denominator). The 0.05 adds a constant ambient component that makes the metric agree with how humans actually perceive contrast in an imperfect room. Don’t rewrite it away.

Integration with themes

FrankenTUI themes compose adaptive colors — light and dark variants for every semantic slot. Whenever a new theme is built, a CI check runs meets_wcag_aa against each fg/bg pair. A theme that fails AA on any slot is rejected at merge time. See the a11y / contrast-checking page for the production check harness.

Pitfalls

Contrast depends on both foreground and background. Changing the theme’s background invalidates every foreground’s audit. Always check pairs, never single colors.

Dimmed text lowers effective contrast. Style::dim() maps to SGR 2, which many terminals render as a mild fade. The luminance shift is terminal-specific; the WCAG helpers do not account for it. Treat dim text as a failure mode for borderline pairs.

Don’t use naive RGB mean for luminance. (r + g + b) / 3 is a common shortcut that gives the wrong answer. Use relative_luminance — it’s already written and it’s cheap.

Where to go next