Skip to content

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

// Package contrast is the Go twin of CosmoDev's src/lib/color.ts (dual source:
// the web lib is TypeScript, the CLI lib is Go — kept in lock-step). Pure +
// deterministic, never panics. The table-driven tests in contrast_test.go share
// vectors with src/lib/color.test.ts so the two implementations are held to the
// same contract.
//
// The math mirrors the WCAG 2.2 relative-luminance and contrast-ratio formulas
// in the TS lib exactly: per-channel sRGB linearization, the weighted
// luminance sum, and the (max+0.05)/(min+0.05) ratio. Invalid hex returns the
// zero value with ok=false, mirroring the TS lib's `null` results.
package contrast

import (
	"math"
	"regexp"
	"strconv"
	"strings"
)

// hex6 matches exactly six hexadecimal digits — the canonical expanded form the
// TS lib validates against with /^[0-9a-fA-F]{6}$/.
var hex6 = regexp.MustCompile(`^[0-9a-fA-F]{6}$`)

// HexToRgb parses a CSS hex color into its 8-bit RGB channels. It accepts an
// optional leading '#', a 3-digit shorthand (expanded by doubling each digit),
// or a full 6-digit form. ok is false (with a zero [3]uint8) when the input is
// not a valid hex color — mirroring hexToRgb()'s `null` in src/lib/color.ts.
func HexToRgb(hex string) (rgb [3]uint8, ok bool) {
	h := strings.TrimPrefix(strings.TrimSpace(hex), "#")
	// Expand a 3-digit shorthand (e.g. "0f0" → "00ff00"). At this point h is
	// ASCII (any non-ASCII input fails the hex6 check below regardless), so
	// byte indexing agrees with the TS lib's per-character doubling.
	if len(h) == 3 {
		h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
	}
	if !hex6.MatchString(h) {
		return [3]uint8{}, false
	}
	r, _ := strconv.ParseUint(h[0:2], 16, 8)
	g, _ := strconv.ParseUint(h[2:4], 16, 8)
	b, _ := strconv.ParseUint(h[4:6], 16, 8)
	return [3]uint8{uint8(r), uint8(g), uint8(b)}, true
}

// channel linearizes a single 8-bit sRGB channel per the WCAG 2.2 transfer
// function. It mirrors the private channel() helper in src/lib/color.ts.
func channel(c uint8) float64 {
	x := float64(c) / 255
	if x <= 0.03928 {
		return x / 12.92
	}
	return math.Pow((x+0.055)/1.055, 2.4)
}

// Luminance returns the WCAG 2.2 relative luminance of a hex color, in [0,1]
// (0 for black, 1 for white). ok is false (with 0) when the hex is invalid —
// mirroring luminance()'s `null` in src/lib/color.ts.
func Luminance(hex string) (lum float64, ok bool) {
	rgb, ok := HexToRgb(hex)
	if !ok {
		return 0, false
	}
	return 0.2126*channel(rgb[0]) + 0.7152*channel(rgb[1]) + 0.0722*channel(rgb[2]), true
}

// ContrastRatio returns the WCAG 2.2 contrast ratio between two hex colors —
// 1:1 for identical colors up to 21:1 for black-on-white. ok is false (with 0)
// when either color is invalid — mirroring contrastRatio()'s `null` in
// src/lib/color.ts.
func ContrastRatio(fg, bg string) (ratio float64, ok bool) {
	l1, ok1 := Luminance(fg)
	l2, ok2 := Luminance(bg)
	if !ok1 || !ok2 {
		return 0, false
	}
	if l1 < l2 {
		l1, l2 = l2, l1
	}
	return (l1 + 0.05) / (l2 + 0.05), true
}

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 →