Skip to content

Color Palette Generator — JavaScript source

Generate harmonious color palettes - complementary, analogous, triadic, tetradic, and monochromatic - from any base color. Export to CSS, Tailwind, or JSON.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * palette-generator - polyglot showcase port (JavaScript).
 *
 * Pure color-theory helpers for the Color Palette Generator. No DOM, no I/O,
 * no external dependencies. All inputs are clamped; nothing throws.
 *
 * Ported from src/lib/colorPalette.ts (TypeScript, the canonical
 * implementation). This is display source - part of CosmoDev's polyglot tool
 * pages (dev.cosmolabs.org), where each tool's pure logic is shown side-by-side
 * in six languages.
 */

// Scheme identifiers selecting which color-harmony rule to apply. This object
// is the runtime analog of the TypeScript `Scheme` string-union type.
export const SCHEMES = Object.freeze({
  COMPLEMENT: 'complement',
  SPLIT_COMPLEMENT: 'split-complement',
  ANALOGOUS: 'analogous',
  TRIADIC: 'triadic',
  TETRADIC: 'tetradic',
  MONOCHROMATIC: 'monochromatic',
});

/**
 * Parse any reasonable hex string (#rgb / #rrggbb, with or without a leading #)
 * into an [r, g, b] byte triple. Unparseable input falls back to black so the
 * generator never throws.
 */
export function hexToRgb(hex) {
  let h = String(hex ?? '').trim().replace(/^#/, '');
  if (/^[0-9a-fA-F]{3}$/.test(h)) {
    // Expand CSS shorthand: each digit doubles (#abc -> #aabbcc).
    h = h.split('').map((c) => c + c).join('');
  }
  if (!/^[0-9a-fA-F]{6}$/.test(h)) return [0, 0, 0];
  return [
    parseInt(h.slice(0, 2), 16),
    parseInt(h.slice(2, 4), 16),
    parseInt(h.slice(4, 6), 16),
  ];
}

// Round to the nearest integer (half rounds toward +Infinity) then clamp to a
// byte. Used when packing float channels back into hex.
function clampByte(n) {
  return Math.max(0, Math.min(255, Math.round(n)));
}

// Pack three float channels into a #rrggbb string (lowercase, zero-padded).
function rgbToHex(r, g, b) {
  const part = (n) => clampByte(n).toString(16).padStart(2, '0');
  return `#${part(r)}${part(g)}${part(b)}`;
}

/**
 * Convert hex → HSL. h ∈ [0, 360), s/l ∈ [0, 100]. Achromatic colors (gray,
 * white, black - no dominant hue) collapse to h = 0, s = 0.
 */
export function hexToHsl(hex) {
  const [r8, g8, b8] = hexToRgb(hex);
  const r = r8 / 255;
  const g = g8 / 255;
  const b = b8 / 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const l = (max + min) / 2;
  let h = 0;
  let s = 0;
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r:
        h = (g - b) / d + (g < b ? 6 : 0);
        break;
      case g:
        h = (b - r) / d + 2;
        break;
      default:
        h = (r - g) / d + 4;
        break;
    }
    h /= 6;
  }
  return [h * 360, s * 100, l * 100];
}

/**
 * Convert HSL → hex. The hue wraps modulo 360 (so h + 150 etc. always land in
 * range) and s/l clamp to [0, 100].
 */
export function hslToHex(h, s, l) {
  // Euclidean-style wrap into [0, 360): JS % keeps the dividend's sign, so add
  // 360 and mod again to handle negative rotations.
  const H = (((h % 360) + 360) % 360);
  const S = Math.max(0, Math.min(100, s)) / 100;
  const L = Math.max(0, Math.min(100, l)) / 100;
  const c = (1 - Math.abs(2 * L - 1)) * S;
  const x = c * (1 - Math.abs(((H / 60) % 2) - 1));
  const m = L - c / 2;
  let r = 0, g = 0, b = 0;
  if (H < 60) [r, g, b] = [c, x, 0];
  else if (H < 120) [r, g, b] = [x, c, 0];
  else if (H < 180) [r, g, b] = [0, c, x];
  else if (H < 240) [r, g, b] = [0, x, c];
  else if (H < 300) [r, g, b] = [x, 0, c];
  else [r, g, b] = [c, 0, x];
  return rgbToHex((r + m) * 255, (g + m) * 255, (b + m) * 255);
}

/**
 * Generate a harmonious palette from a base color.
 *
 * Counts: complement = 2, split-complement = 3, analogous = 3, triadic = 3,
 * tetradic = 4. `count` is honored by monochromatic (default 5): the base hue
 * and saturation are held while lightness is spread across `count` steps.
 */
export function generatePalette(baseHex, scheme, count = 5) {
  const [h, s, l] = hexToHsl(baseHex);
  const base = hslToHex(h, s, l);
  // Rotate the hue by `deg` degrees, keeping saturation and lightness fixed.
  const rot = (deg) => hslToHex(h + deg, s, l);
  switch (scheme) {
    case SCHEMES.COMPLEMENT:
      return [base, rot(180)];
    case SCHEMES.SPLIT_COMPLEMENT:
      return [base, rot(150), rot(210)];
    case SCHEMES.ANALOGOUS:
      return [rot(-30), base, rot(30)];
    case SCHEMES.TRIADIC:
      return [base, rot(120), rot(240)];
    case SCHEMES.TETRADIC:
      return [base, rot(90), rot(180), rot(270)];
    case SCHEMES.MONOCHROMATIC: {
      const n = Math.max(1, Math.floor(count));
      // Pin the lightness window to [10, 90] so swatches never wash out or go
      // fully black, regardless of the base color's own lightness.
      const lo = Math.max(10, l - 32);
      const hi = Math.min(90, l + 32);
      const out = [];
      for (let i = 0; i < n; i++) {
        const ll = n === 1 ? l : lo + ((hi - lo) * i) / (n - 1);
        out.push(hslToHex(h, s, ll));
      }
      return out;
    }
    default:
      return [base];
  }
}

/** `n` shades - the base color mixed progressively toward black (RGB lerp). */
export function shades(baseHex, n) {
  const [r, g, b] = hexToRgb(baseHex);
  const steps = Math.max(1, Math.floor(n));
  const out = [];
  // i runs 1..steps so the fraction i/(steps+1) never hits 0 or 1 - the base
  // itself is never returned, only intermediate steps toward black.
  for (let i = 1; i <= steps; i++) {
    const f = i / (steps + 1);
    out.push(rgbToHex(r * (1 - f), g * (1 - f), b * (1 - f)));
  }
  return out;
}

/** `n` tints - the base color mixed progressively toward white (RGB lerp). */
export function tints(baseHex, n) {
  const [r, g, b] = hexToRgb(baseHex);
  const steps = Math.max(1, Math.floor(n));
  const out = [];
  for (let i = 1; i <= steps; i++) {
    const f = i / (steps + 1);
    out.push(rgbToHex(r + (255 - r) * f, g + (255 - g) * f, b + (255 - b) * f));
  }
  return out;
}

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 →