Skip to content

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 →