Skip to content

Color Contrast Checker — Rust source

Check WCAG 2.2 contrast ratio between any two colors with AA / AAA pass-fail for normal and large text, plus a live preview. For accessible, on-brand design.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! Color contrast — WCAG 2.2 color contrast math.
//! Language: Rust.
//!
//! CosmoDev polyglot showcase port of the `contrast` tool.
//! Ported from src/lib/color.ts — functionally equivalent (same inputs -> same outputs).
//!
//! Display source — part of CosmoDev's polyglot tool pages.

/// An 8-bit sRGB color triple.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgb {
    pub r: u8,
    pub g: u8,
    pub b: u8,
}

/// Parses a CSS-style hex color into an `Rgb` value.
///
/// Accepts an optional leading `#`, a 3-digit shorthand (`#abc`), or the
/// 6-digit form (`#aabbcc`). Returns `None` when the string is not a valid
/// hex color, so callers can distinguish "invalid" from a real color.
pub fn hex_to_rgb(hex: &str) -> Option<Rgb> {
    // Trim surrounding whitespace, then drop exactly one leading '#'
    // (strip_prefix removes a single occurrence, not all of them).
    let trimmed = hex.trim();
    let h = trimmed.strip_prefix('#').unwrap_or(trimmed);

    // Expand CSS shorthand: each hex digit is doubled ("abc" -> "aabbcc").
    let expanded = match h.len() {
        3 => {
            let mut s = String::with_capacity(6);
            for byte in h.bytes() {
                s.push(byte as char);
                s.push(byte as char);
            }
            s
        }
        6 => h.to_string(),
        _ => return None,
    };

    // Six validated hex digits always fit in u32; bit-slice into channels.
    if !expanded.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    let n = u32::from_str_radix(&expanded, 16).ok()?;
    Some(Rgb {
        r: (n >> 16) as u8,
        g: (n >> 8) as u8,
        b: n as u8,
    })
}

/// Linearizes a single sRGB channel (0-255) per WCAG 2.2.
///
/// 8-bit color values are gamma-encoded for display; WCAG luminance is
/// computed in linear-light space using the inverse sRGB transfer function.
/// The small-value branch is the linear segment of that curve.
fn channel(c: u8) -> f64 {
    let v = c as f64 / 255.0;
    if v <= 0.03928 {
        v / 12.92
    } else {
        ((v + 0.055) / 1.055).powf(2.4)
    }
}

/// WCAG relative luminance of a hex color on a 0..1 scale, or `None` if the
/// hex string is invalid.
pub fn luminance(hex: &str) -> Option<f64> {
    let rgb = hex_to_rgb(hex)?;
    // Rec. 709 luma coefficients for the red/green/blue primaries.
    Some(0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b))
}

/// WCAG contrast ratio between two hex colors on a 1..21 scale, or `None` if
/// either color is invalid.
///
/// The 0.05 offset models the ambient luminance assumed by WCAG, keeping the
/// ratio finite (and >= 1) even for identical colors.
pub fn contrast_ratio(fg: &str, bg: &str) -> Option<f64> {
    let l1 = luminance(fg)?;
    let l2 = luminance(bg)?;
    let lighter = l1.max(l2);
    let darker = l1.min(l2);
    Some((lighter + 0.05) / (darker + 0.05))
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →