Skip to content

CSS Gradient Generator — Go source

Build linear, radial, and conic CSS gradients with multiple color stops and positions. Live preview and copy-ready CSS.

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

// Package cssgradient is the Go twin of CosmoDev's src/lib/cssGradient.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). It builds linear/radial/conic CSS-gradient strings. Pure +
// deterministic, never panics. The table-driven tests in
// css-gradient-generator_test.go share vectors with src/lib/cssGradient.test.ts
// so the two implementations are held to the same contract.
//
// The algorithm mirrors the TS lib exactly: parse/validate each stop color,
// stable-sort stops by position ascending, pad to a black→white pair when
// fewer than two stops remain, round each position, then format per gradient
// type. Invalid stop colors fall back to #000000 exactly like normalizeColor
// in the TS lib.
package cssgradient

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

// GradientType is the kind of CSS gradient to build. Mirrors the
// 'linear' | 'radial' | 'conic' literal union in the TS lib.
type GradientType string

const (
	Linear GradientType = "linear"
	Radial GradientType = "radial"
	Conic  GradientType = "conic"
)

// RadialShape is the ending shape of a radial gradient. Mirrors the
// 'circle' | 'ellipse' optional field in the TS lib. The zero value ""
// means "unset" and resolves to the TS default "circle".
type RadialShape string

const (
	Circle  RadialShape = "circle"
	Ellipse RadialShape = "ellipse"
)

// GradientStop is a single color anchor in the gradient. Position is a
// percentage 0..100. Mirrors GradientStop in the TS lib.
type GradientStop struct {
	Color    string
	Position float64
}

// GradientConfig fully describes the gradient to build. Mirrors
// GradientConfig in the TS lib. RadialShape only affects radial gradients;
// its zero value "" resolves to the TS default "circle".
type GradientConfig struct {
	Type        GradientType
	Angle       float64 // degrees (linear direction / conic start)
	Stops       []GradientStop
	RadialShape RadialShape // optional; "" → "circle" (TS default)
}

// ColorResult is the outcome of ParseColor. Mirrors the
// { ok: boolean; error: string | null } return of parseColor in the TS lib.
type ColorResult struct {
	OK    bool
	Error string
}

// Named colors accepted without a hex/function form. Compared after
// trim+lowercase. Mirrors NAMED_COLORS in the TS lib.
var namedColors = map[string]bool{
	"transparent": true, "black": true, "white": true, "red": true,
	"green": true, "blue": true, "yellow": true, "orange": true,
	"purple": true, "pink": true, "gray": true, "grey": true,
	"brown": true, "cyan": true, "magenta": true, "none": true,
	"currentcolor": true,
}

var (
	hex3or6 = regexp.MustCompile(`^#[0-9a-f]{3}([0-9a-f]{3})?$`)
	hex8    = regexp.MustCompile(`^#[0-9a-f]{8}$`)
	rgbaFn  = regexp.MustCompile(`^rgba?\([^)]+\)$`)
	hslaFn  = regexp.MustCompile(`^hsla?\([^)]+\)$`)
)

// ParseColor reports whether a color string is a valid CSS color for gradient
// purposes: a known named color, a 3/6/8-digit hex, or an rgb()/rgba()/
// hsl()/hsla() function call. It is the Go twin of parseColor in the TS lib
// and must agree with it on every shared vector. The error string is non-empty
// iff OK is false (the TS lib's null maps to "").
func ParseColor(color string) ColorResult {
	c := strings.ToLower(strings.TrimSpace(color))
	if c == "" {
		return ColorResult{OK: false, Error: "empty color"}
	}
	if namedColors[c] ||
		hex3or6.MatchString(c) ||
		hex8.MatchString(c) ||
		rgbaFn.MatchString(c) ||
		hslaFn.MatchString(c) {
		return ColorResult{OK: true}
	}
	// Mirrors `invalid color: ${color}` — the original, untrimmed spelling.
	return ColorResult{OK: false, Error: "invalid color: " + color}
}

// normalizeColor returns the trimmed color when it parses, else #000000 —
// the TS lib's fallback for an invalid stop color.
func normalizeColor(color string) string {
	if ParseColor(color).OK {
		return strings.TrimSpace(color)
	}
	return "#000000"
}

// formatNum formats a number the way a JS template literal would, so integer
// angles/positions render without a trailing ".0" (e.g. 90.0 → "90").
func formatNum(n float64) string {
	return strconv.FormatFloat(n, 'f', -1, 64)
}

// BuildGradient renders a CSS gradient string. It is the Go twin of
// buildGradient in the TS lib and must agree with it on every shared vector.
// An unrecognized GradientType yields the empty string (the TS switch is
// exhaustive over the three literals and would return undefined otherwise).
func BuildGradient(config GradientConfig) string {
	// Copy so we never mutate the caller's slice, then stable-sort by
	// position ascending (V8's sort is stable; sort.SliceStable matches).
	stops := make([]GradientStop, len(config.Stops))
	copy(stops, config.Stops)
	sort.SliceStable(stops, func(i, j int) bool {
		return stops[i].Position < stops[j].Position
	})

	// Drop nonsensical entries; pad if fewer than 2.
	if len(stops) < 2 {
		stops = []GradientStop{
			{Color: "#000000", Position: 0},
			{Color: "#ffffff", Position: 100},
		}
	}

	parts := make([]string, len(stops))
	for i, s := range stops {
		parts[i] = normalizeColor(s.Color) + " " + formatNum(math.Round(s.Position)) + "%"
	}
	stopsStr := strings.Join(parts, ", ")

	switch config.Type {
	case Linear:
		return "linear-gradient(" + formatNum(config.Angle) + "deg, " + stopsStr + ")"
	case Radial:
		shape := config.RadialShape
		if shape == "" {
			shape = Circle // TS default via `config.radialShape ?? 'circle'`
		}
		return "radial-gradient(" + string(shape) + ", " + stopsStr + ")"
	case Conic:
		return "conic-gradient(from " + formatNum(config.Angle) + "deg, " + stopsStr + ")"
	}
	return "" // unrecognized type — safe zero value
}

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 →