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 →