Skip to content

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 →