Color Picker & Converter — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Color space conversions + helpers for the Color Picker & Converter tool.
// Pure, deterministic, and clamped - the unit-test surface. Every function is
// total ("no throw"): invalid hex yields `null`, out-of-range numbers are
// clamped into their valid interval. RGB is the canonical hub: every space
// converts through it, and `normalizeColor` rounds the whole cycle so the five
// display formats always agree on a single source of truth.
/** A color resolved into every supported space, all mutually consistent. */
export interface ColorBundle {
hex: string; // `#rrggbb` (lowercase) - the canonical handle
rgb: [number, number, number]; // 0-255 each
hsl: [number, number, number]; // h: 0-360, s/l: 0-100
hsv: [number, number, number]; // h: 0-360, s/v: 0-100
cmyk: [number, number, number, number]; // 0-100 each
}
const clamp = (n: number, min: number, max: number): number => {
if (Number.isNaN(n)) return min; // NaN → lowest valid value
if (n === Infinity) return max; // +∞ → top of range, -∞ → bottom
if (n === -Infinity) return min;
return Math.min(max, Math.max(min, n));
};
/** Clamp to [min,max] and round to the nearest integer. */
const clampInt = (n: number, min: number, max: number): number =>
Math.round(clamp(n, min, max));
/** Clamp a ratio to [0,1]. */
const clamp01 = (n: number): number => clamp(n, 0, 1);
// --- HEX ↔ RGB ---------------------------------------------------------------
/** Parse `#rgb` / `#rrggbb` (case-insensitive, `#` optional) → `[r,g,b]`, or `null`. */
export function hexToRgb(hex: string): [number, number, number] | null {
let h = hex.trim().replace(/^#/, '');
if (/^[0-9a-fA-F]{3}$/.test(h)) h = h.split('').map((c) => c + c).join('');
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
return [
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16),
];
}
/** `[r,g,b]` (clamped to 0-255) → `#rrggbb` (lowercase, zero-padded). */
export function rgbToHex(r: number, g: number, b: number): string {
const byte = (n: number) => clampInt(n, 0, 255).toString(16).padStart(2, '0');
return `#${byte(r)}${byte(g)}${byte(b)}`;
}
// --- RGB ↔ HSL ---------------------------------------------------------------
/** `[r,g,b]` (0-255) → `[h,s,l]` with h: 0-360, s/l: 0-100. */
export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = clamp01(r / 255);
const gn = clamp01(g / 255);
const bn = clamp01(b / 255);
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const d = max - min;
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (d !== 0) {
s = d / (l > 0.5 ? 2 - max - min : max + min);
if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
h *= 60;
}
return [Math.round(h), Math.round(s * 100), Math.round(l * 100)];
}
/** `[h,s,l]` (h: 0-360, s/l: 0-100) → `[r,g,b]` (0-255). */
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const hn = (((Number(h) || 0) % 360) + 360) % 360;
const sn = clamp01(s / 100);
const ln = clamp01(l / 100);
const c = (1 - Math.abs(2 * ln - 1)) * sn;
const x = c * (1 - Math.abs(((hn / 60) % 2) - 1));
const m = ln - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (hn < 60) [r, g, b] = [c, x, 0];
else if (hn < 120) [r, g, b] = [x, c, 0];
else if (hn < 180) [r, g, b] = [0, c, x];
else if (hn < 240) [r, g, b] = [0, x, c];
else if (hn < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
}
/** `#hex` → `[h,s,l]`, or `null` when the hex is invalid. */
export function hexToHsl(hex: string): [number, number, number] | null {
const rgb = hexToRgb(hex);
return rgb ? rgbToHsl(...rgb) : null;
}
/** `[h,s,l]` → `#rrggbb`. */
export function hslToHex(h: number, s: number, l: number): string {
return rgbToHex(...hslToRgb(h, s, l));
}
// --- RGB ↔ HSV ---------------------------------------------------------------
/** `[r,g,b]` (0-255) → `[h,s,v]` with h: 0-360, s/v: 0-100. */
export function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
const rn = clamp01(r / 255);
const gn = clamp01(g / 255);
const bn = clamp01(b / 255);
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
h *= 60;
}
const s = max === 0 ? 0 : d / max;
return [Math.round(h), Math.round(s * 100), Math.round(max * 100)];
}
/** `[h,s,v]` (h: 0-360, s/v: 0-100) → `[r,g,b]` (0-255). */
export function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
const hn = (((Number(h) || 0) % 360) + 360) % 360;
const sn = clamp01(s / 100);
const vn = clamp01(v / 100);
const c = vn * sn;
const x = c * (1 - Math.abs(((hn / 60) % 2) - 1));
const m = vn - c;
let r = 0;
let g = 0;
let b = 0;
if (hn < 60) [r, g, b] = [c, x, 0];
else if (hn < 120) [r, g, b] = [x, c, 0];
else if (hn < 180) [r, g, b] = [0, c, x];
else if (hn < 240) [r, g, b] = [0, x, c];
else if (hn < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
}
/** `[h,s,v]` → `#rrggbb`. */
export function hsvToHex(h: number, s: number, v: number): string {
return rgbToHex(...hsvToRgb(h, s, v));
}
// --- RGB ↔ CMYK --------------------------------------------------------------
/** `[r,g,b]` (0-255) → `[c,m,y,k]` (0-100 each). */
export function rgbToCmyk(r: number, g: number, b: number): [number, number, number, number] {
const rn = clamp01(r / 255);
const gn = clamp01(g / 255);
const bn = clamp01(b / 255);
const k = 1 - Math.max(rn, gn, bn);
if (k === 1) return [0, 0, 0, 100]; // pure black - avoid divide-by-zero
const c = (1 - rn - k) / (1 - k);
const m = (1 - gn - k) / (1 - k);
const y = (1 - bn - k) / (1 - k);
return [Math.round(c * 100), Math.round(m * 100), Math.round(y * 100), Math.round(k * 100)];
}
/** `[c,m,y,k]` (0-100 each) → `[r,g,b]` (0-255). */
export function cmykToRgb(c: number, m: number, y: number, k: number): [number, number, number] {
const cn = clamp01(c / 100);
const mn = clamp01(m / 100);
const yn = clamp01(y / 100);
const kn = clamp01(k / 100);
return [
255 * (1 - cn) * (1 - kn),
255 * (1 - mn) * (1 - kn),
255 * (1 - yn) * (1 - kn),
];
}
/** `[c,m,y,k]` → `#rrggbb`. */
export function cmykToHex(c: number, m: number, y: number, k: number): string {
return rgbToHex(...cmykToRgb(c, m, y, k));
}
// --- Round-robin normalizer --------------------------------------------------
/**
* 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 funnels every edit through, so the five
* display formats can never disagree. Returns `null` for unparseable input.
*/
export function normalizeColor(hex: string): ColorBundle | null {
const rgb = hexToRgb(hex);
if (!rgb) return null;
return {
hex: rgbToHex(...rgb),
rgb,
hsl: rgbToHsl(...rgb),
hsv: rgbToHsv(...rgb),
cmyk: rgbToCmyk(...rgb),
};
}
// --- WCAG luminance, contrast & text suggestion ------------------------------
function srgbChannel(c: number): number {
const s = clamp01(c / 255);
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
/** WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or `null`. */
export function relativeLuminance(hex: string): number | null {
const rgb = hexToRgb(hex);
if (!rgb) return null;
return 0.2126 * srgbChannel(rgb[0]) + 0.7152 * srgbChannel(rgb[1]) + 0.0722 * srgbChannel(rgb[2]);
}
/** WCAG contrast ratio between two hexes (1-21), or `null` if either is invalid. */
export function contrastRatio(a: string, b: string): number | null {
const la = relativeLuminance(a);
const lb = relativeLuminance(b);
if (la == null || lb == null) return null;
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
/** Pick black or white text for maximum legibility on `hex`, or `null` if invalid. */
export function suggestTextHex(hex: string): '#000000' | '#ffffff' | null {
const l = relativeLuminance(hex);
if (l == 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.
const NAMED_COLORS_RAW: ReadonlyArray<{ name: string; hex: string }> = [
{ name: 'black', hex: '#000000' }, { name: 'dim gray', hex: '#696969' },
{ name: 'gray', hex: '#808080' }, { name: 'dark gray', hex: '#a9a9a9' },
{ name: 'silver', hex: '#c0c0c0' }, { name: 'light gray', hex: '#d3d3d3' },
{ name: 'gainsboro', hex: '#dcdcdc' }, { name: 'white smoke', hex: '#f5f5f5' },
{ name: 'white', hex: '#ffffff' }, { name: 'snow', hex: '#fffafa' },
{ name: 'ivory', hex: '#fffff0' }, { name: 'seashell', hex: '#fff5ee' },
{ name: 'red', hex: '#ff0000' }, { name: 'crimson', hex: '#dc143c' },
{ name: 'dark red', hex: '#8b0000' },
{ name: 'firebrick', hex: '#b22222' }, { name: 'indian red', hex: '#cd5c5c' },
{ name: 'salmon', hex: '#fa8072' }, { name: 'tomato', hex: '#ff6347' },
{ name: 'coral', hex: '#ff7f50' }, { name: 'orange', hex: '#ffa500' },
{ name: 'dark orange', hex: '#ff8c00' }, { name: 'gold', hex: '#ffd700' },
{ name: 'chocolate', hex: '#d2691e' }, { name: 'brown', hex: '#a52a2a' },
{ name: 'sienna', hex: '#a0522d' }, { name: 'tan', hex: '#d2b48c' },
{ name: 'yellow', hex: '#ffff00' }, { name: 'khaki', hex: '#f0e68c' },
{ name: 'lime', hex: '#00ff00' }, { name: 'lime green', hex: '#32cd32' },
{ name: 'forest green', hex: '#228b22' }, { name: 'sea green', hex: '#2e8b57' },
{ name: 'green', hex: '#008000' }, { name: 'dark green', hex: '#006400' },
{ name: 'spring green', hex: '#00ff7f' }, { name: 'olive', hex: '#808000' },
{ name: 'teal', hex: '#008080' }, { name: 'dark cyan', hex: '#008b8b' },
{ name: 'turquoise', hex: '#40e0d0' }, { name: 'cyan', hex: '#00ffff' },
{ name: 'sky blue', hex: '#87ceeb' },
{ name: 'deep sky blue', hex: '#00bfff' }, { name: 'steel blue', hex: '#4682b4' },
{ name: 'dodger blue', hex: '#1e90ff' }, { name: 'royal blue', hex: '#4169e1' },
{ name: 'blue', hex: '#0000ff' }, { name: 'navy', hex: '#000080' },
{ name: 'midnight blue', hex: '#191970' }, { name: 'indigo', hex: '#4b0082' },
{ name: 'purple', hex: '#800080' }, { name: 'dark violet', hex: '#9400d3' },
{ name: 'blue violet', hex: '#8a2be2' }, { name: 'medium purple', hex: '#9370db' },
{ name: 'orchid', hex: '#da70d6' }, { name: 'violet', hex: '#ee82ee' },
{ name: 'plum', hex: '#dda0dd' }, { name: 'magenta', hex: '#ff00ff' },
{ name: 'deep pink', hex: '#ff1493' }, { name: 'hot pink', hex: '#ff69b4' },
{ name: 'pink', hex: '#ffc0cb' }, { name: 'lavender', hex: '#e6e6fa' },
];
const NAMED_COLORS: ReadonlyArray<{ name: string; hex: string; rgb: [number, number, number] }> =
NAMED_COLORS_RAW.map((c) => ({ name: c.name, hex: c.hex, rgb: hexToRgb(c.hex)! }));
/**
* Closest entry in the named-color table by squared RGB Euclidean distance.
* Returns `{ name, hex }`, or `null` for unparseable input. Note: the table is
* a curated subset, so "closest" is approximate, not a guarantee of identity.
*/
export function nearestNamedColor(hex: string): { name: string; hex: string } | null {
const rgb = hexToRgb(hex);
if (!rgb) return null;
let best = NAMED_COLORS[0];
let bestD = Infinity;
for (const c of NAMED_COLORS) {
const dr = c.rgb[0] - rgb[0];
const dg = c.rgb[1] - rgb[1];
const db = c.rgb[2] - rgb[2];
const d = dr * dr + dg * dg + db * db;
if (d < bestD) {
bestD = d;
best = c;
}
}
return { name: best.name, hex: 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 →