Skip to content

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 →