Skip to content

Color Picker & Converter — JavaScript 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 JavaScript 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: JavaScript (ES module, ES2020+).
 *
 * CosmoDev polyglot showcase port of the color-picker tool, ported from
 * src/lib/colorConvert.ts. Functionally equivalent: identical inputs produce
 * identical outputs, 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.
 *
 * Display source - part of CosmoDev's polyglot tool pages.
 */

/**
 * @typedef {Object} ColorBundle
 * @property {string} hex - `#rrggbb` (lowercase) - the canonical handle.
 * @property {[number, number, number]} rgb - red, green, blue; 0-255 each.
 * @property {[number, number, number]} hsl - h: 0-360, s/l: 0-100.
 * @property {[number, number, number]} hsv - h: 0-360, s/v: 0-100.
 * @property {[number, number, number, number]} cmyk - c, m, y, k; 0-100 each.
 */

// --- Internal clamp helpers --------------------------------------------------
// Every public function is total: invalid hex yields `null`; out-of-range
// numbers are clamped into their valid interval. NaN degrades to the lowest
// valid value (treated like -Infinity), so corrupt input never throws.

const clamp = (n, min, max) => {
  if (Number.isNaN(n)) return min;
  if (n === Infinity) return max;
  if (n === -Infinity) return min;
  return Math.min(max, Math.max(min, n));
};

const clamp01 = (n) => clamp(n, 0, 1);

// Clamp to [min, max] then round half up to the nearest integer.
const clampInt = (n, min, max) => Math.round(clamp(n, min, max));

// --- HEX ↔ RGB ---------------------------------------------------------------

/**
 * Parse `#rgb` / `#rrggbb` (case-insensitive, `#` optional) → `[r,g,b]`, or
 * `null` for anything that is not a 3- or 6-digit hex color.
 */
export function hexToRgb(hex) {
  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, g, b) {
  const byte = (n) => 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, g, b) {
  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) {
    // Saturation formula branches on which half of the lightness axis we sit on.
    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, s, l) {
  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) {
  const rgb = hexToRgb(hex);
  return rgb ? rgbToHsl(...rgb) : null;
}

/** `[h,s,l]` → `#rrggbb`. */
export function hslToHex(h, s, l) {
  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, g, b) {
  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, s, v) {
  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, s, v) {
  return rgbToHex(...hsvToRgb(h, s, v));
}

// --- RGB ↔ CMYK --------------------------------------------------------------

/** `[r,g,b]` (0-255) → `[c,m,y,k]` (0-100 each). */
export function rgbToCmyk(r, g, b) {
  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, m, y, k) {
  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, m, y, k) {
  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 routes every edit through, so the five
 * display formats can never disagree. Returns `null` for unparseable input.
 *
 * @returns {ColorBundle | null}
 */
export function normalizeColor(hex) {
  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 ------------------------------

// sRGB channel → linear-light, per the WCAG 2.x relative-luminance definition.
function srgbChannel(c) {
  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) {
  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, b) {
  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) {
  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 = [
  { 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' },
];

// Resolve each named color's RGB once at module load so nearest-match lookups
// never re-parse.
const NAMED_COLORS = NAMED_COLORS_RAW.map((c) => ({ ...c, rgb: hexToRgb(c.hex) }));

/**
 * Closest entry in the named-color table by squared RGB Euclidean distance.
 * Returns `{ name, hex }`, or `null` for unparseable input. The table is a
 * curated subset, so "closest" is approximate, not a guarantee of identity.
 */
export function nearestNamedColor(hex) {
  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 →