CSS Gradient Generator — C# source
Build linear, radial, and conic CSS gradients with multiple color stops and positions. Live preview and copy-ready CSS.
This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// =============================================================================
// css-gradient-generator.cs — CosmoDev polyglot showcase port of the
// `css-gradient-generator` tool
// -----------------------------------------------------------------------------
// Language : C# 12 (.NET 8, 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 record. Deterministic and side-effect free;
// invalid input degrades gracefully (unknown colors → solid black, too few
// stops → black/white default ramp) rather than throwing.
// =============================================================================
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace CosmoDev.CssGradient;
/// <summary>CSS gradient kinds we know how to render.</summary>
public enum GradientType
{
Linear,
Radial,
Conic,
}
/// <summary>One color anchor on the gradient ramp. Position is a percentage 0..100.</summary>
public sealed record GradientStop(string Color, double Position);
/// <summary>
/// Full input to <see cref="CssGradient.BuildGradient"/>. <see cref="RadialShape"/>
/// is only meaningful for <see cref="GradientType.Radial"/>; <c>null</c> falls
/// back to "circle" (mirroring the TypeScript <c>?? 'circle'</c> — an explicit
/// "" passes through unchanged).
/// </summary>
public sealed record GradientConfig(
GradientType Type,
double Angle,
IReadOnlyList<GradientStop> Stops,
string? RadialShape = null);
/// <summary>Outcome of <see cref="CssGradient.ParseColor"/>: an ok flag plus a human message (null when ok).</summary>
public readonly record struct ColorResult(bool Ok, string? Error = null);
public static class CssGradient
{
/// <summary>
/// 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).
/// </summary>
private static readonly HashSet<string> NamedColors = new(StringComparer.Ordinal)
{
"transparent", "black", "white", "red", "green", "blue", "yellow", "orange",
"purple", "pink", "gray", "grey", "brown", "cyan", "magenta", "none", "currentcolor",
};
/// <summary>
/// True if a char is a lowercase ASCII hex digit ('0'-'9' or 'a'-'f'). Only
/// the lowercase form is accepted because <see cref="ParseColor"/>
/// lowercases its input before testing, exactly like the TS regex [0-9a-f].
/// </summary>
private static bool IsHexByte(char ch) => ch is (>= '0' and <= '9') or (>= 'a' and <= 'f');
/// <summary>
/// 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.
/// </summary>
private static bool IsHexColor(string c)
{
if (c.Length is not (4 or 7 or 9)) return false; // '#' + {3,6,8} digits
if (c[0] != '#') return false;
foreach (char ch in c[1..])
{
if (!IsHexByte(ch)) return false;
}
return true;
}
/// <summary>
/// Validates a functional color form "name(...)": the string must start
/// with one of <paramref name="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(".
/// </summary>
private static bool IsFunctionalColor(string c, params string[] openers)
{
foreach (string opener in openers)
{
if (!c.StartsWith(opener, StringComparison.Ordinal)) continue;
if (!c.EndsWith(")", StringComparison.Ordinal)) return false;
string body = c[opener.Length..^1];
return body.Length > 0 && !body.Contains(')');
}
return false;
}
/// <summary>
/// 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 (InvariantCulture — color syntax is
/// ASCII) before testing.
/// </summary>
public static ColorResult ParseColor(string? color)
{
string c = (color ?? "").Trim().ToLowerInvariant();
if (c.Length == 0) return new(false, "empty color");
if (NamedColors.Contains(c)) return new(true);
if (IsHexColor(c)) return new(true);
if (IsFunctionalColor(c, "rgba(", "rgb(") || IsFunctionalColor(c, "hsla(", "hsl("))
{
return new(true);
}
return new(false, $"invalid color: {color}");
}
/// <summary>
/// Coerce a possibly-invalid color to a safe value: valid → the trimmed
/// original (casing preserved), invalid → solid black. Guarantees the
/// gradient always has a usable color value.
/// </summary>
private static string NormalizeColor(string color) =>
ParseColor(color).Ok ? color.Trim() : "#000000";
/// <summary>
/// Render a double the way JavaScript's template literal does — shortest
/// round-tripping decimal, with no trailing ".0" on whole numbers (so 90.0
/// becomes "90", matching String(90)). .NET's default double formatting is
/// already shortest-round-trip, so only the culture must be pinned.
/// </summary>
private static string FormatNumber(double x) => x.ToString(CultureInfo.InvariantCulture);
/// <summary>
/// Render a complete CSS gradient string.
///
/// Stops are sorted ascending by position (OrderBy is stable, matching
/// modern JavaScript's Array.sort; a NaN position — out of domain — sorts
/// last per Double.CompareTo). Fewer than two stops collapse to a
/// black → white default ramp so the output is always renderable.
/// Positions round via floor(x + 0.5), which agrees with Math.round on the
/// non-negative 0..100 position domain.
/// </summary>
public static string BuildGradient(GradientConfig config)
{
var stops = config.Stops.OrderBy(s => s.Position).ToList();
if (stops.Count < 2)
{
stops = [new("#000000", 0), new("#ffffff", 100)];
}
string stopsStr = string.Join(", ",
stops.Select(s => $"{NormalizeColor(s.Color)} {(long)Math.Floor(s.Position + 0.5)}%"));
string angle = FormatNumber(config.Angle);
return config.Type switch
{
GradientType.Linear => $"linear-gradient({angle}deg, {stopsStr})",
// `?? 'circle'`: null → "circle"; non-null → verbatim (even if empty).
GradientType.Radial => $"radial-gradient({config.RadialShape ?? "circle"}, {stopsStr})",
GradientType.Conic => $"conic-gradient(from {angle}deg, {stopsStr})",
_ => "", // unreachable for the enum — mirrors the TS fall-through
};
}
}
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 →