Skip to content

CSS Gradient Generator — Zig source

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

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

// =============================================================================
//  css-gradient-generator.zig — CosmoDev polyglot showcase port of the
//  `css-gradient-generator` tool
//  -----------------------------------------------------------------------------
//  Language : Zig (0.13, standard library only)
//  Source:   ported from src/lib/cssGradient.ts (the canonical, live TypeScript
//             lib); mirrors src/tool-sources/css-gradient-generator/{python.py,rust.rs}
//  License  : display source — part of CosmoDev's polyglot tool pages
//             (dev.cosmolabs.org). Shown verbatim alongside the JS/TS/Go/Rust/
//             Python ports and the other language ports.
//  -----------------------------------------------------------------------------
//  Pure CSS-gradient builder. Build linear / radial / conic CSS gradient
//  strings from a small config. Deterministic; invalid input degrades
//  gracefully (unknown colors → solid black, too few stops → black/white
//  default ramp) rather than erroring. Every function takes an explicit
//  allocator; returned memory is owned by the caller.
// =============================================================================

const std = @import("std");

/// CSS gradient kinds we know how to render.
pub const GradientType = enum { linear, radial, conic };

/// One color anchor on the gradient ramp. Position is a percentage 0..100.
pub const GradientStop = struct {
    color: []const u8,
    position: f64,
};

/// Full input to `buildGradient`. `radial_shape` is only meaningful for
/// `.radial`; null falls back to "circle" (mirroring the TypeScript
/// `?? 'circle'` — an explicit "" passes through unchanged).
pub const GradientConfig = struct {
    type: GradientType,
    angle: f64,
    stops: []const GradientStop,
    radial_shape: ?[]const u8 = null,
};

/// Outcome of `parseColor`: an `ok` flag plus a human message (null when ok).
/// When set, `error_message` is allocated from the same allocator — free it.
pub const ColorResult = struct {
    ok: bool,
    error_message: ?[]const u8 = null,
};

/// Named CSS colors this tool accepts. The full CSS spec defines ~148, but we
/// intentionally accept only the common, unambiguous set so output stays
/// predictable (mirrors the TypeScript allow-list).
const named_colors = [_][]const u8{
    "transparent", "black", "white", "red", "green", "blue", "yellow", "orange",
    "purple",       "pink",  "gray",  "grey", "brown", "cyan", "magenta", "none", "currentcolor",
};

/// Whitespace JS String.prototype.trim() strips.
const ws = " \t\n\r\x0b\x0c";

/// True if a byte is a lowercase ASCII hex digit ('0'-'9' or 'a'-'f'). Only the
/// lowercase form is accepted because `parseColor` lowercases its input before
/// testing, exactly like the TS regex `[0-9a-f]`.
fn isHexByte(b: u8) bool {
    return (b >= '0' and b <= '9') or (b >= 'a' and b <= 'f');
}

/// Validates a hex color by shape: '#' followed by exactly 3, 6, or 8 lowercase
/// hex digits. This collapses the two TS regexes (`#[0-9a-f]{3}([0-9a-f]{3})?`
/// and `#[0-9a-f]{8}`) into one structural check.
fn isHexColor(c: []const u8) bool {
    const valid_len = c.len == 4 or c.len == 7 or c.len == 9; // '#' + {3,6,8} digits
    if (!valid_len or c[0] != '#') return false;
    for (c[1..]) |b| {
        if (!isHexByte(b)) return false;
    }
    return true;
}

/// Validates a functional color form "name(...)": the string must start with
/// one of `openers` (e.g. "rgba(", "rgb("), end with ')', and have a nonempty
/// body containing no ')'. Mirrors the TS `^rgba?\([^)]+\)$` / `^hsla?\([^)]+\)$`.
/// Longer openers must come first so "rgba(" is tried before "rgb(".
fn isFunctionalColor(c: []const u8, openers: []const []const u8) bool {
    for (openers) |opener| {
        if (std.mem.startsWith(u8, c, opener)) {
            if (!std.mem.endsWith(u8, c, ")")) return false;
            const body = c[opener.len .. c.len - 1]; // c ends with ')' ⇒ bounds are safe
            return body.len > 0 and std.mem.indexOfScalar(u8, body, ')') == null;
        }
    }
    return false;
}

/// Validate a CSS color string.
///
/// Accepts named colors, #RGB / #RRGGBB / #RRGGBBAA hex, and rgb()/rgba()/
/// hsl()/hsla() functional forms. The input is trimmed and lowercased (into
/// scratch memory — the original is never modified) before testing.
pub fn parseColor(allocator: std.mem.Allocator, color: []const u8) std.mem.Allocator.Error!ColorResult {
    const trimmed = std.mem.trim(u8, color, ws);
    const c = try std.ascii.allocLowerString(allocator, trimmed);
    defer allocator.free(c);

    if (c.len == 0) {
        return .{ .ok = false, .error_message = try allocator.dupe(u8, "empty color") };
    }
    for (named_colors) |name| {
        if (std.mem.eql(u8, name, c)) return .{ .ok = true };
    }
    if (isHexColor(c)) return .{ .ok = true };
    if (isFunctionalColor(c, &.{ "rgba(", "rgb(" }) or isFunctionalColor(c, &.{ "hsla(", "hsl(" })) {
        return .{ .ok = true };
    }
    return .{
        .ok = false,
        .error_message = try std.fmt.allocPrint(allocator, "invalid color: {s}", .{color}),
    };
}

/// Coerce a possibly-invalid color to a safe value: valid → the trimmed
/// original (casing preserved — a borrow, no allocation), invalid → solid
/// black. Guarantees the gradient always has a usable color value.
fn normalizeColor(allocator: std.mem.Allocator, color: []const u8) std.mem.Allocator.Error![]const u8 {
    const res = try parseColor(allocator, color);
    if (res.error_message) |msg| allocator.free(msg);
    return if (res.ok) std.mem.trim(u8, color, ws) else "#000000";
}

/// Round the way JavaScript's Math.round does (half toward +infinity). Zig has
/// no float rounding builtin that agrees with Math.round on .5 values; flooring
/// (x + 0.5) matches for all non-negative inputs — the gradient-position
/// domain.
fn jsRound(x: f64) i64 {
    return @intFromFloat(@floor(x + 0.5));
}

/// Render a float into `buf` the way JavaScript's template literal does.
///
/// Zig's `{d}` float formatting produces the shortest round-tripping decimal
/// in decimal notation (Ryu under the hood), and it prints no trailing ".0" on
/// whole numbers — so 90.0 renders as "90", matching String(90). The buffer
/// always fits this tool's plain-decimal angle domain (angles and percentages,
/// not astronomically-scaled exponents).
fn formatNumber(buf: []u8, x: f64) []const u8 {
    return std.fmt.bufPrint(buf, "{d}", .{x}) catch unreachable;
}

/// Ascending by position. NaN positions (out of domain) compare "not less"
/// against everything, so they stay put — insertion sort below keeps them in
/// input order.
fn lessThanPosition(_: void, a: GradientStop, b: GradientStop) bool {
    return a.position < b.position;
}

/// Render a complete CSS gradient string (allocated with `allocator`; the
/// caller owns and must free it).
///
/// Stops are sorted ascending by position. std.sort.insertion is stable
/// (insertion sort), matching modern JavaScript's Array.sort. Fewer than two
/// stops collapse to a black → white default ramp so the output is always
/// renderable. The enum switch is exhaustive, which replaces the TypeScript's
/// fall-off-the-switch undefined return — an unknown type is unrepresentable.
pub fn buildGradient(allocator: std.mem.Allocator, config: GradientConfig) std.mem.Allocator.Error![]u8 {
    const stops = try allocator.dupe(GradientStop, config.stops);
    defer allocator.free(stops);
    std.sort.insertion(GradientStop, stops, {}, lessThanPosition);

    const defaults = [_]GradientStop{
        .{ .color = "#000000", .position = 0.0 },
        .{ .color = "#ffffff", .position = 100.0 },
    };
    const ramp: []const GradientStop = if (stops.len < 2) &defaults else stops;

    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    const writer = out.writer();

    var angle_buf: [48]u8 = undefined;
    const angle = formatNumber(&angle_buf, config.angle);

    switch (config.type) {
        .linear => try writer.print("linear-gradient({s}deg, ", .{angle}),
        .radial => {
            // `?? 'circle'`: null → "circle"; non-null → verbatim (even if empty).
            const shape = config.radial_shape orelse "circle";
            try writer.print("radial-gradient({s}, ", .{shape});
        },
        .conic => try writer.print("conic-gradient(from {s}deg, ", .{angle}),
    }

    for (ramp, 0..) |stop, i| {
        if (i != 0) try writer.writeAll(", ");
        const color = try normalizeColor(allocator, stop.color);
        try writer.print("{s} {d}%", .{ color, jsRound(stop.position) });
    }
    try writer.writeAll(")");

    return out.toOwnedSlice();
}

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 →