Skip to content

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

// Color contrast — WCAG 2.2 color contrast math.
// Language: Kotlin 1.9 — standard library only (kotlin.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.

import java.util.Locale
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow

/**
 * An 8-bit sRGB color triple.
 */
data class Rgb(val r: Int, val g: Int, val b: Int)

/**
 * 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 `null` when the string is not a valid
 * hex color, so callers can distinguish "invalid" from a real color.
 */
fun hexToRgb(color: String): Rgb? {
    // Trim surrounding whitespace, then drop exactly one leading '#'
    // (removePrefix strips a single occurrence, not all of them).
    var h = color.trim()
    if (h.startsWith("#")) h = h.removePrefix("#")

    val expanded = when (h.length) {
        // Expand CSS shorthand: each hex digit is doubled ("abc" -> "aabbcc").
        3 -> buildString {
            for (c in h) {
                append(c)
                append(c)
            }
        }
        6 -> h
        else -> return null
    }

    if (!expanded.matches(Regex("[0-9a-fA-F]{6}"))) return null
    return Rgb(
        expanded.substring(0, 2).toInt(16),
        expanded.substring(2, 4).toInt(16),
        expanded.substring(4, 6).toInt(16),
    )
}

/**
 * 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.
 */
fun channel(c: Int): Double {
    val v = c / 255.0
    return if (v <= 0.03928) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4)
}

/**
 * WCAG relative luminance of a hex color on a 0..1 scale, or `null` if the
 * hex string is invalid.
 */
fun luminance(color: String): Double? {
    val rgb = hexToRgb(color) ?: return null
    // 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 `null` 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.
 */
fun contrastRatio(fg: String, bg: String): Double? {
    val l1 = luminance(fg) ?: return null
    val l2 = luminance(bg) ?: return null
    val lighter = max(l1, l2)
    val darker = min(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)
}

// Demo — run: kotlin kotlin.kt
fun report(fg: String, bg: String) {
    val ratio = contrastRatio(fg, bg)
    val body = ratio?.let { String.format(Locale.ROOT, "%.2f", it) } ?: "invalid"
    println("contrast $fg on $bg = $body")
}

fun main() {
    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 →