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 →