Color Palette Generator — Zig source
Generate harmonious color palettes - complementary, analogous, triadic, tetradic, and monochromatic - from any base color. Export to CSS, Tailwind, or JSON.
This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
// palette-generator — harmonious palettes from a base color.
// Zig 0.13+, stdlib only. CosmoDev polyglot port of src/lib/colorPalette.ts.
// All inputs clamped; bad hex falls back to black. Display source.
const std = @import("std");
const math = std.math;
const Hex = [7]u8; // "#rrggbb"
const Hsl = struct { h: f64, s: f64, l: f64 }; // h in [0,360), s/l in [0,100]
fn hexVal(c: u8) ?u4 {
return switch (c) {
'0'...'9' => @intCast(c - '0'),
'a'...'f' => @intCast(c - 'a' + 10),
'A'...'F' => @intCast(c - 'A' + 10),
else => null,
};
}
/// Parse #rgb / #rrggbb (leading # optional); fallback black.
fn hexToRgb(hex: []const u8) [3]u8 {
const p = std.mem.trimLeft(u8, hex, "# ");
if (p.len != 3 and p.len != 6) return .{ 0, 0, 0 };
var b: [6]u8 = undefined;
if (p.len == 3) b = .{ p[0], p[0], p[1], p[1], p[2], p[2] } else @memcpy(&b, p);
var out: [3]u8 = .{ 0, 0, 0 };
for (0..3) |i| {
const hi = hexVal(b[i * 2]) orelse return .{ 0, 0, 0 };
out[i] = @as(u8, hi) * 16 + @as(u8, hexVal(b[i * 2 + 1]) orelse return .{ 0, 0, 0 });
}
return out;
}
/// Round half up (TS Math.round), then clamp to a byte.
fn clampByte(n: f64) u8 {
return @intFromFloat(math.clamp(@floor(n + 0.5), 0, 255));
}
fn rgbToHex(r: f64, g: f64, b: f64) Hex {
var out: Hex = undefined;
_ = std.fmt.bufPrint(&out, "#{x:0>2}{x:0>2}{x:0>2}", .{ clampByte(r), clampByte(g), clampByte(b) }) catch unreachable;
return out;
}
/// hex -> HSL. Achromatic colors give h = 0, s = 0.
fn hexToHsl(hex: []const u8) Hsl {
const rgb = hexToRgb(hex);
const r = @as(f64, @floatFromInt(rgb[0])) / 255;
const g = @as(f64, @floatFromInt(rgb[1])) / 255;
const b = @as(f64, @floatFromInt(rgb[2])) / 255;
const mx = @max(r, @max(g, b)), mn = @min(r, @min(g, b));
var h: f64 = 0;
var s: f64 = 0;
const l = (mx + mn) / 2;
if (mx != mn) {
const d = mx - mn;
s = if (l > 0.5) d / (2 - mx - mn) else d / (mx + mn);
if (mx == r) { h = (g - b) / d + @as(f64, if (g < b) 6 else 0); }
else if (mx == g) { h = (b - r) / d + 2; } else { h = (r - g) / d + 4; }
h /= 6;
}
return .{ .h = h * 360, .s = s * 100, .l = l * 100 };
}
/// HSL -> hex. @mod is floored (true) modulo; the double wrap guards the f64
/// edge where a slightly negative hue lands on exactly 360.
fn hslToHex(h: f64, s: f64, l: f64) Hex {
const H = @mod(@mod(h, 360) + 360, 360);
const S = math.clamp(s, 0, 100) / 100;
const L = math.clamp(l, 0, 100) / 100;
const c = (1 - @abs(2 * L - 1)) * S;
const x = c * (1 - @abs(@mod(H / 60, 2) - 1));
const m = L - c / 2;
const w = [6][3]f64{ .{ c, x, 0 }, .{ x, c, 0 }, .{ 0, c, x }, .{ 0, x, c }, .{ x, 0, c }, .{ c, 0, x } };
const rgb = w[@intFromFloat(@min(H / 60, 5))];
return rgbToHex((rgb[0] + m) * 255, (rgb[1] + m) * 255, (rgb[2] + m) * 255);
}
const Harmony = struct { name: []const u8, n: usize, rot: [4]f64 };
const SCHEMES = [_]Harmony{
.{ .name = "complement", .n = 2, .rot = .{ 0, 180, 0, 0 } },
.{ .name = "split-complement", .n = 3, .rot = .{ 0, 150, 210, 0 } },
.{ .name = "analogous", .n = 3, .rot = .{ -30, 0, 30, 0 } },
.{ .name = "triadic", .n = 3, .rot = .{ 0, 120, 240, 0 } },
.{ .name = "tetradic", .n = 4, .rot = .{ 0, 90, 180, 270 } },
};
/// Generate a palette into `out`; returns the count written. `count` steers
/// only the monochromatic lightness spread (hue/saturation held).
fn generatePalette(baseHex: []const u8, scheme: []const u8, count: usize, out: []Hex) usize {
const base = hexToHsl(baseHex);
var n: usize = 0;
for (SCHEMES) |sc| {
if (!std.mem.eql(u8, scheme, sc.name)) continue;
for (sc.rot[0..sc.n]) |deg| {
out[n] = hslToHex(base.h + deg, base.s, base.l);
n += 1;
}
return n;
}
if (std.mem.eql(u8, scheme, "monochromatic")) {
const steps = @max(1, count);
const lo = @max(10.0, base.l - 32), hi = @min(90.0, base.l + 32);
var i: usize = 0;
while (i < steps) : (i += 1) {
const ll = if (steps == 1) base.l
else lo + (hi - lo) * @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(steps - 1));
out[n] = hslToHex(base.h, base.s, ll);
n += 1;
}
} else {
out[0] = hslToHex(base.h, base.s, base.l); // unknown scheme -> base only
n = 1;
}
return n;
}
/// n shades / tints — RGB lerp toward black / white; i runs 1..n so the base
/// color itself is never returned, only intermediate steps.
fn mix(baseHex: []const u8, n: usize, out: []Hex, towardWhite: bool) void {
const rgb = hexToRgb(baseHex);
const c: [3]f64 = .{ @floatFromInt(rgb[0]), @floatFromInt(rgb[1]), @floatFromInt(rgb[2]) };
const steps = @max(1, n);
var i: usize = 1;
while (i <= steps) : (i += 1) {
const f = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(steps + 1));
out[i - 1] = if (towardWhite)
rgbToHex(c[0] + (255 - c[0]) * f, c[1] + (255 - c[1]) * f, c[2] + (255 - c[2]) * f)
else
rgbToHex(c[0] * (1 - f), c[1] * (1 - f), c[2] * (1 - f));
}
}
fn shades(baseHex: []const u8, n: usize, out: []Hex) void {
mix(baseHex, n, out, false);
}
fn tints(baseHex: []const u8, n: usize, out: []Hex) void {
mix(baseHex, n, out, 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 →