Color Picker & Converter — C# source
Pick a color and convert between HEX, RGB, HSL, HSV, and CMYK with a live preview. Edit any format and copy the rest - runs entirely in your browser.
This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// color-picker — color-space conversions, WCAG contrast, and named-color lookup.
//
// Language: C# 12 on .NET 8 (System + System.Linq only).
//
// Source: CosmoDev polyglot showcase port of the color-picker tool, ported
// from src/lib/colorConvert.ts. Functionally equivalent: identical
// outputs for identical inputs, including clamping, NaN/Infinity
// handling, and null on invalid hex.
//
// RGB is the canonical hub: every other space converts through it, and
// NormalizeColor() re-derives the hex from its own clamped RGB so the five
// display formats can never disagree.
//
// License: display source — part of CosmoDev's polyglot tool pages.
using System;
using System.Linq;
namespace CosmoDev.ColorPicker;
/// <summary>A color resolved into every supported space, all mutually consistent.</summary>
public readonly record struct ColorBundle(
string Hex, // #rrggbb (lowercase) — the canonical handle
double[] Rgb, // 0–255 each
double[] Hsl, // h: 0–360, s/l: 0–100
double[] Hsv, // h: 0–360, s/v: 0–100
double[] Cmyk); // 0–100 each
/// <summary>A named CSS color with its hex.</summary>
public readonly record struct NamedColor(string Name, string Hex);
public static class ColorPicker
{
// --- Internal clamp helpers --------------------------------------------------
// Every public function is total: invalid hex → null; out-of-range numbers
// are clamped into their valid interval. Math.Min/Max propagate NaN, so NaN
// is handled explicitly before they ever see it.
private static double ClampRange(double n, double lo, double hi) =>
double.IsNaN(n) ? lo :
double.IsPositiveInfinity(n) ? hi :
double.IsNegativeInfinity(n) ? lo :
Math.Min(hi, Math.Max(lo, n));
private static double Clamp01(double n) => ClampRange(n, 0.0, 1.0);
// Round half away from zero — mirrors JS Math.round for the non-negative
// values this module rounds. The explicit Floor(n + 0.5) form documents the
// parity with the TypeScript source.
private static double RoundHalfUp(double n) => Math.Floor(n + 0.5);
// Normalize any hue (negative, >360, or NaN) into [0, 360), matching
// JS ((Number(h) || 0) % 360 + 360) % 360. C# % takes the dividend's sign.
private static double Mod360(double h)
{
if (double.IsNaN(h)) h = 0.0;
return ((h % 360.0) + 360.0) % 360.0;
}
private static bool IsHexDigits(string s) =>
s.Length > 0 && s.All(char.IsAsciiHexDigit);
private static double HexByteAt(string h, int start) =>
Convert.ToInt32(h.Substring(start, 2), 16);
// --- HEX ↔ RGB ---------------------------------------------------------------
/// <summary>Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into
/// [r, g, b], or null for anything that is not a 3- or 6-digit hex color.</summary>
public static double[]? HexToRgb(string value)
{
var h = value.Trim();
if (h.StartsWith('#')) h = h[1..]; // TS strips exactly one leading '#'.
if (h.Length == 3 && IsHexDigits(h)) // expand shorthand #rgb → #rrggbb
h = $"{h[0]}{h[0]}{h[1]}{h[1]}{h[2]}{h[2]}";
if (h.Length != 6 || !IsHexDigits(h)) return null;
return [HexByteAt(h, 0), HexByteAt(h, 2), HexByteAt(h, 4)];
}
/// <summary>[r, g, b] (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded).</summary>
public static string RgbToHex(double r, double g, double b)
{
static string Byte(double n) =>
((int)Math.Floor(ClampRange(n, 0.0, 255.0) + 0.5)).ToString("x2");
return $"#{Byte(r)}{Byte(g)}{Byte(b)}";
}
// --- RGB ↔ HSL ---------------------------------------------------------------
/// <summary>[r, g, b] (0–255) → [h, s, l] with h: 0–360, s/l: 0–100.</summary>
public static double[] RgbToHsl(double r, double g, double b)
{
double rn = Clamp01(r / 255.0), gn = Clamp01(g / 255.0), bn = Clamp01(b / 255.0);
double mx = Math.Max(rn, Math.Max(gn, bn));
double mn = Math.Min(rn, Math.Min(gn, bn));
double d = mx - mn;
double l = (mx + mn) / 2.0;
double h = 0.0, s = 0.0;
if (d != 0.0)
{
// Saturation formula branches on which half of the lightness axis we sit on.
s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn);
if (mx == rn) h = (gn - bn) / d + (gn < bn ? 6.0 : 0.0);
else if (mx == gn) h = (bn - rn) / d + 2.0;
else h = (rn - gn) / d + 4.0;
h *= 60.0;
}
return [RoundHalfUp(h), RoundHalfUp(s * 100.0), RoundHalfUp(l * 100.0)];
}
/// <summary>[h, s, l] (h: 0–360, s/l: 0–100) → [r, g, b] (0–255).</summary>
public static double[] HslToRgb(double h, double s, double l)
{
double hn = Mod360(h);
double sn = Clamp01(s / 100.0);
double ln = Clamp01(l / 100.0);
double c = (1.0 - Math.Abs(2.0 * ln - 1.0)) * sn;
double x = c * (1.0 - Math.Abs(hn / 60.0 % 2.0 - 1.0));
double m = ln - c / 2.0;
double rr, gg, bb;
if (hn < 60.0) (rr, gg, bb) = (c, x, 0.0);
else if (hn < 120.0) (rr, gg, bb) = (x, c, 0.0);
else if (hn < 180.0) (rr, gg, bb) = (0.0, c, x);
else if (hn < 240.0) (rr, gg, bb) = (0.0, x, c);
else if (hn < 300.0) (rr, gg, bb) = (x, 0.0, c);
else (rr, gg, bb) = (c, 0.0, x);
return [(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0];
}
/// <summary>"#hex" → [h, s, l], or null when the hex is invalid.</summary>
public static double[]? HexToHsl(string value)
{
var rgb = HexToRgb(value);
return rgb is null ? null : RgbToHsl(rgb[0], rgb[1], rgb[2]);
}
/// <summary>[h, s, l] → "#rrggbb".</summary>
public static string HslToHex(double h, double s, double l)
{
var rgb = HslToRgb(h, s, l);
return RgbToHex(rgb[0], rgb[1], rgb[2]);
}
// --- RGB ↔ HSV ---------------------------------------------------------------
/// <summary>[r, g, b] (0–255) → [h, s, v] with h: 0–360, s/v: 0–100.</summary>
public static double[] RgbToHsv(double r, double g, double b)
{
double rn = Clamp01(r / 255.0), gn = Clamp01(g / 255.0), bn = Clamp01(b / 255.0);
double mx = Math.Max(rn, Math.Max(gn, bn));
double mn = Math.Min(rn, Math.Min(gn, bn));
double d = mx - mn;
double h = 0.0;
if (d != 0.0)
{
if (mx == rn) h = (gn - bn) / d + (gn < bn ? 6.0 : 0.0);
else if (mx == gn) h = (bn - rn) / d + 2.0;
else h = (rn - gn) / d + 4.0;
h *= 60.0;
}
double s = mx == 0.0 ? 0.0 : d / mx;
return [RoundHalfUp(h), RoundHalfUp(s * 100.0), RoundHalfUp(mx * 100.0)];
}
/// <summary>[h, s, v] (h: 0–360, s/v: 0–100) → [r, g, b] (0–255).</summary>
public static double[] HsvToRgb(double h, double s, double v)
{
double hn = Mod360(h);
double sn = Clamp01(s / 100.0);
double vn = Clamp01(v / 100.0);
double c = vn * sn;
double x = c * (1.0 - Math.Abs(hn / 60.0 % 2.0 - 1.0));
double m = vn - c;
double rr, gg, bb;
if (hn < 60.0) (rr, gg, bb) = (c, x, 0.0);
else if (hn < 120.0) (rr, gg, bb) = (x, c, 0.0);
else if (hn < 180.0) (rr, gg, bb) = (0.0, c, x);
else if (hn < 240.0) (rr, gg, bb) = (0.0, x, c);
else if (hn < 300.0) (rr, gg, bb) = (x, 0.0, c);
else (rr, gg, bb) = (c, 0.0, x);
return [(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0];
}
/// <summary>[h, s, v] → "#rrggbb".</summary>
public static string HsvToHex(double h, double s, double v)
{
var rgb = HsvToRgb(h, s, v);
return RgbToHex(rgb[0], rgb[1], rgb[2]);
}
// --- RGB ↔ CMYK --------------------------------------------------------------
/// <summary>[r, g, b] (0–255) → [c, m, y, k] (0–100 each).</summary>
public static double[] RgbToCmyk(double r, double g, double b)
{
double rn = Clamp01(r / 255.0), gn = Clamp01(g / 255.0), bn = Clamp01(b / 255.0);
double k = 1.0 - Math.Max(rn, Math.Max(gn, bn));
if (k == 1.0) return [0.0, 0.0, 0.0, 100.0]; // pure black — avoid divide-by-zero
double c = (1.0 - rn - k) / (1.0 - k);
double m = (1.0 - gn - k) / (1.0 - k);
double y = (1.0 - bn - k) / (1.0 - k);
return [RoundHalfUp(c * 100.0), RoundHalfUp(m * 100.0), RoundHalfUp(y * 100.0), RoundHalfUp(k * 100.0)];
}
/// <summary>[c, m, y, k] (0–100 each) → [r, g, b] (0–255).</summary>
public static double[] CmykToRgb(double c, double m, double y, double k)
{
double cn = Clamp01(c / 100.0), mn = Clamp01(m / 100.0), yn = Clamp01(y / 100.0), kn = Clamp01(k / 100.0);
return [
255.0 * (1.0 - cn) * (1.0 - kn),
255.0 * (1.0 - mn) * (1.0 - kn),
255.0 * (1.0 - yn) * (1.0 - kn),
];
}
/// <summary>[c, m, y, k] → "#rrggbb".</summary>
public static string CmykToHex(double c, double m, double y, double k)
{
var rgb = CmykToRgb(c, m, y, k);
return RgbToHex(rgb[0], rgb[1], rgb[2]);
}
// --- Round-robin normalizer --------------------------------------------------
/// <summary>Resolve any hex into one consistent ColorBundle: the hex is
/// re-derived from its own clamped RGB, then HSL/HSV/CMYK are all computed
/// from that same RGB. This is the single funnel the UI routes every edit
/// through, so the five display formats can never disagree. Returns null
/// for invalid hex.</summary>
public static ColorBundle? NormalizeColor(string value)
{
var rgb = HexToRgb(value);
if (rgb is null) return null;
double r = rgb[0], g = rgb[1], b = rgb[2];
return new ColorBundle(
Hex: RgbToHex(r, g, b),
Rgb: rgb,
Hsl: RgbToHsl(r, g, b),
Hsv: RgbToHsv(r, g, b),
Cmyk: RgbToCmyk(r, g, b));
}
// --- WCAG luminance, contrast & text suggestion ------------------------------
// Linearize a single sRGB channel (0–255) per the WCAG 2.x definition.
private static double SrgbChannel(double c)
{
double s = Clamp01(c / 255.0);
return s <= 0.03928 ? s / 12.92 : Math.Pow((s + 0.055) / 1.055, 2.4);
}
/// <summary>WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or null.</summary>
public static double? RelativeLuminance(string value)
{
var rgb = HexToRgb(value);
if (rgb is null) return null;
return 0.2126 * SrgbChannel(rgb[0])
+ 0.7152 * SrgbChannel(rgb[1])
+ 0.0722 * SrgbChannel(rgb[2]);
}
/// <summary>WCAG contrast ratio between two hexes (1–21), or null if either is invalid.</summary>
public static double? ContrastRatio(string a, string b)
{
double? la = RelativeLuminance(a);
double? lb = RelativeLuminance(b);
if (la is null || lb is null) return null;
double hi = Math.Max(la.Value, lb.Value);
double lo = Math.Min(la.Value, lb.Value);
return (hi + 0.05) / (lo + 0.05);
}
/// <summary>Pick black or white text for maximum legibility on value, or null if invalid.</summary>
public static string? SuggestTextHex(string value)
{
double? l = RelativeLuminance(value);
if (l is null) return null;
return l > 0.179 ? "#000000" : "#ffffff";
}
// --- Closest named CSS color -------------------------------------------------
// A curated set of well-known CSS named colors. Kept intentionally to
// entries whose hex is verifiable from memory — exhaustive tables typed by
// hand risk shipping wrong data, which unit tests cannot catch.
private static readonly (string Name, string Hex)[] NamedColorsRaw =
[
("black", "#000000"), ("dim gray", "#696969"),
("gray", "#808080"), ("dark gray", "#a9a9a9"),
("silver", "#c0c0c0"), ("light gray", "#d3d3d3"),
("gainsboro", "#dcdcdc"), ("white smoke", "#f5f5f5"),
("white", "#ffffff"), ("snow", "#fffafa"),
("ivory", "#fffff0"), ("seashell", "#fff5ee"),
("red", "#ff0000"), ("crimson", "#dc143c"),
("dark red", "#8b0000"),
("firebrick", "#b22222"), ("indian red", "#cd5c5c"),
("salmon", "#fa8072"), ("tomato", "#ff6347"),
("coral", "#ff7f50"), ("orange", "#ffa500"),
("dark orange", "#ff8c00"), ("gold", "#ffd700"),
("chocolate", "#d2691e"), ("brown", "#a52a2a"),
("sienna", "#a0522d"), ("tan", "#d2b48c"),
("yellow", "#ffff00"), ("khaki", "#f0e68c"),
("lime", "#00ff00"), ("lime green", "#32cd32"),
("forest green", "#228b22"), ("sea green", "#2e8b57"),
("green", "#008000"), ("dark green", "#006400"),
("spring green", "#00ff7f"), ("olive", "#808000"),
("teal", "#008080"), ("dark cyan", "#008b8b"),
("turquoise", "#40e0d0"), ("cyan", "#00ffff"),
("sky blue", "#87ceeb"),
("deep sky blue", "#00bfff"), ("steel blue", "#4682b4"),
("dodger blue", "#1e90ff"), ("royal blue", "#4169e1"),
("blue", "#0000ff"), ("navy", "#000080"),
("midnight blue", "#191970"), ("indigo", "#4b0082"),
("purple", "#800080"), ("dark violet", "#9400d3"),
("blue violet", "#8a2be2"), ("medium purple", "#9370db"),
("orchid", "#da70d6"), ("violet", "#ee82ee"),
("plum", "#dda0dd"), ("magenta", "#ff00ff"),
("deep pink", "#ff1493"), ("hot pink", "#ff69b4"),
("pink", "#ffc0cb"), ("lavender", "#e6e6fa"),
];
private sealed record ResolvedNamed(string Name, string Hex, double[] Rgb);
// Precompute each named color's RGB once at type init, mirroring the TS
// source's eager resolution so nearest-match lookups never re-parse.
private static readonly ResolvedNamed[] NamedColors = NamedColorsRaw
.Select(e => new ResolvedNamed(e.Name, e.Hex, HexToRgb(e.Hex)!))
.ToArray();
/// <summary>Closest entry in the named-color table by squared RGB Euclidean
/// distance. Returns null for invalid input. The table is a curated subset,
/// so "closest" is approximate, not a guarantee of identity.</summary>
public static NamedColor? NearestNamedColor(string value)
{
var rgb = HexToRgb(value);
if (rgb is null) return null;
double r = rgb[0], g = rgb[1], b = rgb[2];
ResolvedNamed best = NamedColors[0];
double bestD = double.PositiveInfinity;
foreach (var entry in NamedColors)
{
double dr = entry.Rgb[0] - r, dg = entry.Rgb[1] - g, db = entry.Rgb[2] - b;
double d = dr * dr + dg * dg + db * db;
if (d < bestD)
{
bestD = d;
best = entry;
}
}
return new NamedColor(best.Name, best.Hex);
}
}
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 →