Skip to content

Color Contrast Checker — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Color contrast — WCAG 2.2 color contrast math.
// Language: Zig 0.13 — standard library only (std.math.pow).
// Source: CosmoDev polyglot showcase port of the `contrast` tool — canonical
// src/lib/color.ts + this tool's python.py / rust.rs (same inputs -> same outputs).
// License: display source — part of CosmoDev's polyglot tool pages.

const std = @import("std");

/// An 8-bit sRGB color triple.
const Rgb = struct {
    r: u8,
    g: u8,
    b: u8,
};

/// Value of a single hex digit, or null when the byte is not hex.
fn hexDigit(c: u8) ?u4 {
    return switch (c) {
        '0'...'9' => @intCast(c - '0'),
        'a'...'f' => @intCast(c - 'a' + 10),
        'A'...'F' => @intCast(c - 'A' + 10),
        else => null,
    };
}

/// 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 `error.InvalidHex` when the string is
/// not a valid hex color, so callers can distinguish "invalid" from a real
/// color.
fn hexToRgb(color: []const u8) error{InvalidHex}!Rgb {
    // Trim surrounding whitespace, then drop exactly one leading '#'
    // (a single occurrence, not all of them — matches CSS).
    var s = std.mem.trim(u8, color, " \t\r\n");
    if (s.len > 0 and s[0] == '#') s = s[1..];

    // Expand CSS shorthand: each hex digit is doubled ("abc" -> "aabbcc").
    var digits: [6]u8 = undefined;
    if (s.len == 3) {
        for (s, 0..) |c, i| {
            digits[i * 2] = c;
            digits[i * 2 + 1] = c;
        }
    } else if (s.len == 6) {
        @memcpy(&digits, s);
    } else {
        return error.InvalidHex;
    }

    // Six validated hex digits: two per channel, high nibble first.
    var channels: [3]u8 = undefined;
    var i: usize = 0;
    while (i < 6) : (i += 2) {
        const hi = hexDigit(digits[i]) orelse return error.InvalidHex;
        const lo = hexDigit(digits[i + 1]) orelse return error.InvalidHex;
        channels[i / 2] = (@as(u8, hi) << 4) | @as(u8, lo);
    }
    return .{ .r = channels[0], .g = channels[1], .b = channels[2] };
}

/// 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 {
    const v = @as(f64, @floatFromInt(c)) / 255.0;
    return if (v <= 0.03928) v / 12.92 else std.math.pow(f64, (v + 0.055) / 1.055, 2.4);
}

/// WCAG relative luminance of a hex color on a 0..1 scale, or
/// `error.InvalidHex` if the hex string is invalid.
fn luminance(color: []const u8) error{InvalidHex}!f64 {
    const rgb = try hexToRgb(color);
    // Rec. 709 luma coefficients for the red/green/blue primaries.
    return 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
/// `error.InvalidHex` 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.
fn contrastRatio(fg: []const u8, bg: []const u8) error{InvalidHex}!f64 {
    const l1 = try luminance(fg);
    const l2 = try luminance(bg);
    return (@max(l1, l2) + 0.05) / (@min(l1, l2) + 0.05);
}

// Demo — run: zig run zig.zig
fn report(fg: []const u8, bg: []const u8) void {
    const stdout = std.io.getStdOut().writer();
    if (contrastRatio(fg, bg)) |ratio| {
        stdout.print("contrast {s} on {s} = {d:.2}\n", .{ fg, bg, ratio }) catch {};
    } else |_| {
        stdout.print("contrast {s} on {s} = invalid\n", .{ fg, bg }) catch {};
    }
}

pub fn main() void {
    report("#000000", "#ffffff"); // 21.00
    report("#767676", "#ffffff"); // 4.54
    report("nope", "#ffffff"); // invalid
}

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 →