Skip to content

Color Picker & Converter — Go source

Pick a color and convert between HEX, RGB, HSL, HSV, and CMYK with a live preview. Edit any format and copy the rest - runs entirely in your browser.

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

// Package colorpicker is the Go twin of CosmoDev's src/lib/colorConvert.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
// color-picker_test.go share vectors with src/lib/colorConvert.test.ts so the
// two implementations are held to the same contract.
//
// RGB is the canonical hub: every space converts through it, and Normalize
// rounds the whole cycle so the five display formats always agree on a single
// source of truth. Mirrors the TS algorithm and public surface exactly. Every
// function is total: invalid hex yields (zero, false), out-of-range numbers
// are clamped into their valid interval — matching the TS "no throw" contract.
package colorpicker

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

// RGB is a color in the sRGB space, channels 0–255.
type RGB struct{ R, G, B int }

// HSL is hue (0–360), saturation and lightness (0–100).
type HSL struct{ H, S, L int }

// HSV is hue (0–360), saturation and value (0–100).
type HSV struct{ H, S, V int }

// CMYK is cyan/magenta/yellow/key, each 0–100.
type CMYK struct{ C, M, Y, K int }

// ColorBundle is a color resolved into every supported space, all mutually
// consistent (RGB is the single source of truth).
type ColorBundle struct {
	Hex  string
	RGB  RGB
	HSL  HSL
	HSV  HSV
	CMYK CMYK
}

// NamedColor is a named CSS color and its hex.
type NamedColor struct {
	Name string
	Hex  string
}

// clamp mirrors the TS clamp: NaN→min, +Inf→max, −Inf→min, else clamp to range.
func clamp(n, min, max float64) float64 {
	if math.IsNaN(n) {
		return min
	}
	if math.IsInf(n, 1) {
		return max
	}
	if math.IsInf(n, -1) {
		return min
	}
	return math.Min(max, math.Max(min, n))
}

// clampInt clamps to [min,max] then rounds to the nearest integer (TS clampInt).
// The value is provably non-negative at every call site, so Go's math.Round
// (round half away from zero) agrees with TS Math.round (round half up) here.
func clampInt(n, min, max float64) float64 {
	return math.Round(clamp(n, min, max))
}

// clamp01 clamps a ratio to [0,1] (TS clamp01).
func clamp01(n float64) float64 {
	return clamp(n, 0, 1)
}

// wrapHue mirrors `(((Number(h)||0)%360)+360)%360` from the TS lib: NaN maps to
// 0 (the `||0` fallback), then the hue is normalized into [0,360). For ±Inf the
// modulo yields NaN, exactly as in JS, so the sextant comparisons all fall
// through to the default branch — no panic.
func wrapHue(h float64) float64 {
	if math.IsNaN(h) {
		return 0
	}
	hn := math.Mod(h, 360)
	hn = math.Mod(hn+360, 360)
	return hn
}

// --- HEX ↔ RGB ---------------------------------------------------------------

// HexToRGB parses "#rgb"/"#rrggbb" (case-insensitive, '#' optional, surrounding
// whitespace tolerated) into RGB. It returns (RGB{}, false) for invalid input —
// mirroring the TS `null` return, never panicking.
func HexToRGB(hex string) (RGB, bool) {
	h := strings.TrimPrefix(strings.TrimSpace(hex), "#")
	if len(h) == 3 && isAllHex(h) {
		h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
	}
	if len(h) != 6 || !isAllHex(h) {
		return RGB{}, false
	}
	return RGB{R: parseHexByte(h[0:2]), G: parseHexByte(h[2:4]), B: parseHexByte(h[4:6])}, true
}

// RGBToHex encodes clamped RGB channels (0–255) as "#rrggbb" (lowercase,
// zero-padded). Channels may be any float; NaN/±Inf clamp like the TS lib.
func RGBToHex(r, g, b float64) string {
	return "#" + hexByte(r) + hexByte(g) + hexByte(b)
}

// --- RGB ↔ HSL ---------------------------------------------------------------

// RGBToHSL converts clamped RGB (0–255) to HSL with H 0–360, S/L 0–100.
func RGBToHSL(r, g, b float64) HSL {
	rn := clamp01(r / 255)
	gn := clamp01(g / 255)
	bn := clamp01(b / 255)
	max := math.Max(rn, math.Max(gn, bn))
	min := math.Min(rn, math.Min(gn, bn))
	d := max - min
	l := (max + min) / 2
	var h, s float64
	if d != 0 {
		if l > 0.5 {
			s = d / (2 - max - min)
		} else {
			s = d / (max + min)
		}
		switch {
		case max == rn:
			h = (gn-bn)/d
			if gn < bn {
				h += 6
			}
		case max == gn:
			h = (bn-rn)/d + 2
		default: // max == bn
			h = (rn-gn)/d + 4
		}
		h *= 60
	}
	return HSL{H: int(math.Round(h)), S: int(math.Round(s * 100)), L: int(math.Round(l * 100))}
}

// HSLToRGB converts HSL (H 0–360, S/L 0–100) to RGB (0–255). The intermediate
// float channels are rounded into RGB; this is safe because RGBToHex rounds
// again and rounding is idempotent (round(round(x)) == round(x)), so the
// observable HSLToHex output is identical to the TS lib.
func HSLToRGB(h, s, l float64) RGB {
	hn := wrapHue(h)
	sn := clamp01(s / 100)
	ln := clamp01(l / 100)
	c := (1 - math.Abs(2*ln-1)) * sn
	x := c * (1 - math.Abs(math.Mod(hn/60, 2)-1))
	m := ln - c/2
	r, g, b := sextant(hn, c, x)
	return RGB{
		R: int(math.Round((r + m) * 255)),
		G: int(math.Round((g + m) * 255)),
		B: int(math.Round((b + m) * 255)),
	}
}

// HexToHSL converts a hex to HSL, returning (HSL{}, false) for invalid hex.
func HexToHSL(hex string) (HSL, bool) {
	rgb, ok := HexToRGB(hex)
	if !ok {
		return HSL{}, false
	}
	return RGBToHSL(float64(rgb.R), float64(rgb.G), float64(rgb.B)), true
}

// HSLToHex converts HSL (H 0–360, S/L 0–100) to "#rrggbb".
func HSLToHex(h, s, l float64) string {
	rgb := HSLToRGB(h, s, l)
	return RGBToHex(float64(rgb.R), float64(rgb.G), float64(rgb.B))
}

// --- RGB ↔ HSV ---------------------------------------------------------------

// RGBToHSV converts clamped RGB (0–255) to HSV with H 0–360, S/V 0–100.
func RGBToHSV(r, g, b float64) HSV {
	rn := clamp01(r / 255)
	gn := clamp01(g / 255)
	bn := clamp01(b / 255)
	max := math.Max(rn, math.Max(gn, bn))
	min := math.Min(rn, math.Min(gn, bn))
	d := max - min
	var h float64
	if d != 0 {
		switch {
		case max == rn:
			h = (gn-bn)/d
			if gn < bn {
				h += 6
			}
		case max == gn:
			h = (bn-rn)/d + 2
		default: // max == bn
			h = (rn-gn)/d + 4
		}
		h *= 60
	}
	s := 0.0
	if max != 0 {
		s = d / max
	}
	return HSV{H: int(math.Round(h)), S: int(math.Round(s * 100)), V: int(math.Round(max * 100))}
}

// HSVToRGB converts HSV (H 0–360, S/V 0–100) to RGB (0–255). See HSLToRGB for
// why rounding the intermediate channels is parity-safe.
func HSVToRGB(h, s, v float64) RGB {
	hn := wrapHue(h)
	sn := clamp01(s / 100)
	vn := clamp01(v / 100)
	c := vn * sn
	x := c * (1 - math.Abs(math.Mod(hn/60, 2)-1))
	m := vn - c
	r, g, b := sextant(hn, c, x)
	return RGB{
		R: int(math.Round((r + m) * 255)),
		G: int(math.Round((g + m) * 255)),
		B: int(math.Round((b + m) * 255)),
	}
}

// HSVToHex converts HSV (H 0–360, S/V 0–100) to "#rrggbb".
func HSVToHex(h, s, v float64) string {
	rgb := HSVToRGB(h, s, v)
	return RGBToHex(float64(rgb.R), float64(rgb.G), float64(rgb.B))
}

// --- RGB ↔ CMYK --------------------------------------------------------------

// RGBToCMYK converts clamped RGB (0–255) to CMYK (0–100 each). Pure black
// (max channel 0) short-circuits to [0,0,0,100], avoiding divide-by-zero.
func RGBToCMYK(r, g, b float64) CMYK {
	rn := clamp01(r / 255)
	gn := clamp01(g / 255)
	bn := clamp01(b / 255)
	k := 1 - math.Max(rn, math.Max(gn, bn))
	if k == 1 {
		return CMYK{0, 0, 0, 100}
	}
	c := (1 - rn - k) / (1 - k)
	m := (1 - gn - k) / (1 - k)
	y := (1 - bn - k) / (1 - k)
	return CMYK{
		C: int(math.Round(c * 100)),
		M: int(math.Round(m * 100)),
		Y: int(math.Round(y * 100)),
		K: int(math.Round(k * 100)),
	}
}

// CMYKToRGB converts clamped CMYK (0–100 each) to RGB (0–255).
func CMYKToRGB(c, m, y, k float64) RGB {
	cn := clamp01(c / 100)
	mn := clamp01(m / 100)
	yn := clamp01(y / 100)
	kn := clamp01(k / 100)
	return RGB{
		R: int(math.Round(255 * (1 - cn) * (1 - kn))),
		G: int(math.Round(255 * (1 - mn) * (1 - kn))),
		B: int(math.Round(255 * (1 - yn) * (1 - kn))),
	}
}

// CMYKToHex converts CMYK (0–100 each) to "#rrggbb".
func CMYKToHex(c, m, y, k float64) string {
	rgb := CMYKToRGB(c, m, y, k)
	return RGBToHex(float64(rgb.R), float64(rgb.G), float64(rgb.B))
}

// --- Round-robin normalizer --------------------------------------------------

// Normalize resolves any hex into one consistent ColorBundle: the hex is
// re-derived from its own clamped RGB, then HSL/HSV/CMYK are all computed from
// that same RGB. Returns (ColorBundle{}, false) for unparseable input.
func Normalize(hex string) (ColorBundle, bool) {
	rgb, ok := HexToRGB(hex)
	if !ok {
		return ColorBundle{}, false
	}
	rf, gf, bf := float64(rgb.R), float64(rgb.G), float64(rgb.B)
	return ColorBundle{
		Hex:  RGBToHex(rf, gf, bf),
		RGB:  rgb,
		HSL:  RGBToHSL(rf, gf, bf),
		HSV:  RGBToHSV(rf, gf, bf),
		CMYK: RGBToCMYK(rf, gf, bf),
	}, true
}

// --- WCAG luminance, contrast & text suggestion ------------------------------

// srgbChannel linearizes one sRGB channel (TS srgbChannel).
func srgbChannel(c float64) float64 {
	s := clamp01(c / 255)
	if s <= 0.03928 {
		return s / 12.92
	}
	return math.Pow((s+0.055)/1.055, 2.4)
}

// RelativeLuminance returns the WCAG 2.x relative luminance of a hex (0 = black,
// 1 = white), or (0, false) for invalid input.
func RelativeLuminance(hex string) (float64, bool) {
	rgb, ok := HexToRGB(hex)
	if !ok {
		return 0, false
	}
	return 0.2126*srgbChannel(float64(rgb.R)) +
		0.7152*srgbChannel(float64(rgb.G)) +
		0.0722*srgbChannel(float64(rgb.B)), true
}

// ContrastRatio returns the WCAG contrast ratio between two hexes (1–21), or
// (0, false) if either hex is invalid.
func ContrastRatio(a, b string) (float64, bool) {
	la, oka := RelativeLuminance(a)
	lb, okb := RelativeLuminance(b)
	if !oka || !okb {
		return 0, false
	}
	return (math.Max(la, lb) + 0.05) / (math.Min(la, lb) + 0.05), true
}

// SuggestTextHex picks black or white text for maximum legibility on a hex, or
// ("", false) for invalid input.
func SuggestTextHex(hex string) (string, bool) {
	l, ok := RelativeLuminance(hex)
	if !ok {
		return "", false
	}
	if l > 0.179 {
		return "#000000", true
	}
	return "#ffffff", true
}

// --- Closest named CSS color -------------------------------------------------

// namedRaw is the curated set of well-known CSS named colors (same entries as
// NAMED_COLORS_RAW in src/lib/colorConvert.ts). RGB is precomputed at package
// load, mirroring the TS NAMED_COLORS table.
type namedRaw struct{ name, hex string }

var namedRawList = []namedRaw{
	{"black", "#000000"}, {"dim gray", "#696969"},
	{"gray", "#808080"}, {"dark gray", "#a9a9a9"},
	{"silver", "#c0c0c0"}, {"light gray", "#d3d3d3"},
	{"gainsboro", "#dcdcdc"}, {"white smoke", "#f5f5f5"},
	{"white", "#ffffff"}, {"snow", "#fffafa"},
	{"ivory", "#fffff0"}, {"seashell", "#fff5ee"},
	{"red", "#ff0000"}, {"crimson", "#dc143c"},
	{"dark red", "#8b0000"},
	{"firebrick", "#b22222"}, {"indian red", "#cd5c5c"},
	{"salmon", "#fa8072"}, {"tomato", "#ff6347"},
	{"coral", "#ff7f50"}, {"orange", "#ffa500"},
	{"dark orange", "#ff8c00"}, {"gold", "#ffd700"},
	{"chocolate", "#d2691e"}, {"brown", "#a52a2a"},
	{"sienna", "#a0522d"}, {"tan", "#d2b48c"},
	{"yellow", "#ffff00"}, {"khaki", "#f0e68c"},
	{"lime", "#00ff00"}, {"lime green", "#32cd32"},
	{"forest green", "#228b22"}, {"sea green", "#2e8b57"},
	{"green", "#008000"}, {"dark green", "#006400"},
	{"spring green", "#00ff7f"}, {"olive", "#808000"},
	{"teal", "#008080"}, {"dark cyan", "#008b8b"},
	{"turquoise", "#40e0d0"}, {"cyan", "#00ffff"},
	{"sky blue", "#87ceeb"},
	{"deep sky blue", "#00bfff"}, {"steel blue", "#4682b4"},
	{"dodger blue", "#1e90ff"}, {"royal blue", "#4169e1"},
	{"blue", "#0000ff"}, {"navy", "#000080"},
	{"midnight blue", "#191970"}, {"indigo", "#4b0082"},
	{"purple", "#800080"}, {"dark violet", "#9400d3"},
	{"blue violet", "#8a2be2"}, {"medium purple", "#9370db"},
	{"orchid", "#da70d6"}, {"violet", "#ee82ee"},
	{"plum", "#dda0dd"}, {"magenta", "#ff00ff"},
	{"deep pink", "#ff1493"}, {"hot pink", "#ff69b4"},
	{"pink", "#ffc0cb"}, {"lavender", "#e6e6fa"},
}

type namedEntry struct {
	name string
	hex  string
	rgb  RGB
}

// namedColors is NAMED_COLORS_RAW pre-resolved to RGB, mirroring the TS
// NAMED_COLORS table built at module load.
var namedColors = func() []namedEntry {
	out := make([]namedEntry, len(namedRawList))
	for i, c := range namedRawList {
		rgb, _ := HexToRGB(c.hex)
		out[i] = namedEntry{name: c.name, hex: c.hex, rgb: rgb}
	}
	return out
}()

// NearestNamedColor returns the closest entry in the named-color table by
// squared RGB Euclidean distance, or (NamedColor{}, false) for unparseable
// input. Strict less-than keeps the first entry on ties, matching the TS lib.
func NearestNamedColor(hex string) (NamedColor, bool) {
	rgb, ok := HexToRGB(hex)
	if !ok {
		return NamedColor{}, false
	}
	best := namedColors[0]
	bestD := math.Inf(1)
	for _, c := range namedColors {
		dr := float64(c.rgb.R - rgb.R)
		dg := float64(c.rgb.G - rgb.G)
		db := float64(c.rgb.B - rgb.B)
		d := dr*dr + dg*dg + db*db
		if d < bestD {
			bestD = d
			best = c
		}
	}
	return NamedColor{Name: best.name, Hex: best.hex}, true
}

// --- helpers -----------------------------------------------------------------

// isAllHex reports whether every byte of s is an ASCII hex digit. Used to mirror
// the TS /^[0-9a-fA-F]{n}$/ regex checks.
func isAllHex(s string) bool {
	for i := 0; i < len(s); i++ {
		c := s[i]
		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
			return false
		}
	}
	return true
}

// parseHexByte parses a 2-char hex byte (caller validates it is hex).
func parseHexByte(s string) int {
	v, _ := strconv.ParseInt(s, 16, 0)
	return int(v)
}

// hexByte clamps n to [0,255], rounds, and zero-pads to two lowercase hex
// digits — mirroring TS clampInt(n,0,255).toString(16).padStart(2,'0').
func hexByte(n float64) string {
	s := strconv.FormatInt(int64(clampInt(n, 0, 255)), 16)
	if len(s) < 2 {
		s = "0" + s
	}
	return s
}

// sextant returns the (r,g,b) chroma triple for hue hn in [0,360) given chroma c
// and the intermediate value x, mirroring the shared 60° branch logic of the TS
// hslToRgb / hsvToRgb.
func sextant(hn, c, x float64) (r, g, b float64) {
	switch {
	case hn < 60:
		return c, x, 0
	case hn < 120:
		return x, c, 0
	case hn < 180:
		return 0, c, x
	case hn < 240:
		return 0, x, c
	case hn < 300:
		return x, 0, c
	default:
		return c, 0, x
	}
}

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 →