Skip to content

Color Palette Generator — Go source

Generate harmonious color palettes - complementary, analogous, triadic, tetradic, and monochromatic - from any base color. Export to CSS, Tailwind, or JSON.

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

// Package palettegenerator is the Go twin of CosmoDev's src/lib/colorPalette.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
// palette-generator_test.go share vectors with src/lib/colorPalette.test.ts so
// the two implementations are held to the same contract.
//
// HSL is the harmonies hub: GeneratePalette converts the base hex → HSL, then
// rotates the hue by fixed per-scheme offsets and renders each swatch back to
// hex. All inputs are clamped; nothing throws — invalid input falls back to
// black, mirroring the TS lib exactly.
package palettegenerator

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

// Scheme names the harmony the palette is built around. It mirrors the Scheme
// string union in src/lib/colorPalette.ts; the constants carry the same string
// values so the two twins accept identical input.
type Scheme string

const (
	SchemeComplement      Scheme = "complement"
	SchemeSplitComplement Scheme = "split-complement"
	SchemeAnalogous       Scheme = "analogous"
	SchemeTriadic         Scheme = "triadic"
	SchemeTetradic        Scheme = "tetradic"
	SchemeMonochromatic   Scheme = "monochromatic"
)

// HexToRGB parses any reasonable hex string (#rgb / #rrggbb, with or without
// the leading #) into [r, g, b] bytes. It is the Go twin of hexToRgb() in the
// TS lib and falls back to black on anything that is not 3 or 6 hex digits.
func HexToRGB(hex string) [3]byte {
	h := strings.TrimSpace(strings.TrimPrefix(hex, "#"))
	if isHex3(h) {
		// Double each digit, exactly like the TS shorthand expansion.
		h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
	}
	if !isHex6(h) {
		return [3]byte{0, 0, 0}
	}
	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]byte{byte(r), byte(g), byte(b)}
}

// clampByte rounds n to the nearest integer and clamps it to the byte range
// [0, 255], mirroring the TS clampByte helper (round-then-clamp).
func clampByte(n float64) byte {
	return byte(math.Max(0, math.Min(255, math.Round(n))))
}

// rgbToHex renders three linear-RGB channels as a lowercase #rrggbb string,
// clamping each channel to [0, 255]. It mirrors the unexported rgbToHex in the
// TS lib.
func rgbToHex(r, g, b float64) string {
	return "#" + hexByte(clampByte(r)) + hexByte(clampByte(g)) + hexByte(clampByte(b))
}

// hexByte formats a byte as two lowercase hex digits.
func hexByte(n byte) string {
	const hexdigits = "0123456789abcdef"
	return string([]byte{hexdigits[n>>4], hexdigits[n&0x0f]})
}

// HexToHSL converts hex → HSL. h ∈ [0, 360), s/l ∈ [0, 100]; achromatic colors
// return h = 0. It is the Go twin of hexToHsl() in the TS lib.
func HexToHSL(hex string) [3]float64 {
	rgb := HexToRGB(hex)
	r := float64(rgb[0]) / 255
	g := float64(rgb[1]) / 255
	b := float64(rgb[2]) / 255
	max := math.Max(r, math.Max(g, b))
	min := math.Min(r, math.Min(g, b))
	l := (max + min) / 2
	var h, s float64
	if max != min {
		d := max - min
		if l > 0.5 {
			s = d / (2 - max - min)
		} else {
			s = d / (max + min)
		}
		switch max {
		case r:
			h = (g-b)/d
			if g < b {
				h += 6
			}
		case g:
			h = (b-r)/d + 2
		default: // max == b
			h = (r-g)/d + 4
		}
		h /= 6
	}
	return [3]float64{h * 360, s * 100, l * 100}
}

// HSLToHex converts HSL → hex. Inputs are clamped: h wraps mod 360, s/l clamp
// to [0, 100]. It is the Go twin of hslToHex() in the TS lib.
func HSLToHex(h, s, l float64) string {
	H := math.Mod(math.Mod(h, 360)+360, 360)
	S := math.Max(0, math.Min(100, s)) / 100
	L := math.Max(0, math.Min(100, l)) / 100
	c := (1 - math.Abs(2*L-1)) * S
	x := c * (1 - math.Abs(math.Mod(H/60, 2)-1))
	m := L - c/2
	var r, g, b float64
	switch {
	case H < 60:
		r, g, b = c, x, 0
	case H < 120:
		r, g, b = x, c, 0
	case H < 180:
		r, g, b = 0, c, x
	case H < 240:
		r, g, b = 0, x, c
	case H < 300:
		r, g, b = x, 0, c
	default:
		r, g, b = c, 0, x
	}
	return rgbToHex((r+m)*255, (g+m)*255, (b+m)*255)
}

// GeneratePalette builds a harmonious palette from a base color. It is the Go
// twin of generatePalette() in the TS lib.
//
// Counts: complement=2, split-complement=3, analogous=3, triadic=3,
// tetradic=4. The optional count (default 5) is honored only by monochromatic,
// which holds the base hue/saturation and spreads lightness across count steps.
func GeneratePalette(baseHex string, scheme Scheme, count ...int) []string {
	n := 5
	if len(count) > 0 {
		n = count[0]
	}
	hsl := HexToHSL(baseHex)
	h, s, l := hsl[0], hsl[1], hsl[2]
	base := HSLToHex(h, s, l)
	rot := func(deg float64) string { return HSLToHex(h+deg, s, l) }
	switch scheme {
	case SchemeComplement:
		return []string{base, rot(180)}
	case SchemeSplitComplement:
		return []string{base, rot(150), rot(210)}
	case SchemeAnalogous:
		return []string{rot(-30), base, rot(30)}
	case SchemeTriadic:
		return []string{base, rot(120), rot(240)}
	case SchemeTetradic:
		return []string{base, rot(90), rot(180), rot(270)}
	case SchemeMonochromatic:
		m := n
		if m < 1 {
			m = 1
		}
		lo := math.Max(10, l-32)
		hi := math.Min(90, l+32)
		out := make([]string, 0, m)
		for i := 0; i < m; i++ {
			var ll float64
			if m == 1 {
				ll = l
			} else {
				ll = lo + (hi-lo)*float64(i)/float64(m-1)
			}
			out = append(out, HSLToHex(h, s, ll))
		}
		return out
	default:
		return []string{base}
	}
}

// Shades returns n colors of the base mixed progressively toward black (RGB
// lerp). It is the Go twin of shades() in the TS lib.
func Shades(baseHex string, n int) []string {
	rgb := HexToRGB(baseHex)
	r, g, b := float64(rgb[0]), float64(rgb[1]), float64(rgb[2])
	steps := n
	if steps < 1 {
		steps = 1
	}
	out := make([]string, 0, steps)
	for i := 1; i <= steps; i++ {
		f := float64(i) / float64(steps+1)
		out = append(out, rgbToHex(r*(1-f), g*(1-f), b*(1-f)))
	}
	return out
}

// Tints returns n colors of the base mixed progressively toward white (RGB
// lerp). It is the Go twin of tints() in the TS lib.
func Tints(baseHex string, n int) []string {
	rgb := HexToRGB(baseHex)
	r, g, b := float64(rgb[0]), float64(rgb[1]), float64(rgb[2])
	steps := n
	if steps < 1 {
		steps = 1
	}
	out := make([]string, 0, steps)
	for i := 1; i <= steps; i++ {
		f := float64(i) / float64(steps+1)
		out = append(out, rgbToHex(r+(255-r)*f, g+(255-g)*f, b+(255-b)*f))
	}
	return out
}

// ContrastText returns the text color ("#000000" or "#ffffff") with the higher
// contrast ratio against hex, using WCAG relative luminance. The crossover is
// L ≈ 0.179, where black-on-color and white-on-color ratios are equal. Invalid
// input parses as black (see HexToRGB) so it returns white. Never panics.
//
// It is the Go twin of contrastText() in the TS lib; vectors are shared with
// src/lib/colorPalette.test.ts.
func ContrastText(hex string) string {
	rgb := HexToRGB(hex)
	L := 0.2126*linearize(rgb[0]) + 0.7152*linearize(rgb[1]) + 0.0722*linearize(rgb[2])
	if L > 0.179 {
		return "#000000"
	}
	return "#ffffff"
}

// linearize converts an 8-bit sRGB channel to linear light per the WCAG
// transform (the same piecewise function used by contrastText in the TS lib).
func linearize(c8 byte) float64 {
	c := float64(c8) / 255
	if c <= 0.04045 {
		return c / 12.92
	}
	return math.Pow((c+0.055)/1.055, 2.4)
}

// isHex3 reports whether s is exactly 3 hexadecimal digits (the #rgb shorthand).
func isHex3(s string) bool {
	if len(s) != 3 {
		return false
	}
	for i := 0; i < 3; i++ {
		if !isHexChar(s[i]) {
			return false
		}
	}
	return true
}

// isHex6 reports whether s is exactly 6 hexadecimal digits (#rrggbb).
func isHex6(s string) bool {
	if len(s) != 6 {
		return false
	}
	for i := 0; i < 6; i++ {
		if !isHexChar(s[i]) {
			return false
		}
	}
	return true
}

// isHexChar reports whether c is a single hexadecimal digit (case-insensitive).
func isHexChar(c byte) bool {
	switch {
	case c >= '0' && c <= '9':
		return true
	case c >= 'a' && c <= 'f':
		return true
	case c >= 'A' && c <= 'F':
		return true
	}
	return false
}

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 →