Skip to content

Color Palette Generator — TypeScript source

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

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

// Pure color-theory helpers for the Color Palette Generator.
// No DOM / React. All inputs are clamped; nothing throws.

export type Scheme =
  | 'complement'
  | 'split-complement'
  | 'analogous'
  | 'triadic'
  | 'tetradic'
  | 'monochromatic';

/** Parse any reasonable hex string (#rgb / #rrggbb, with or without #) to [r,g,b] bytes. Falls back to black. */
export function hexToRgb(hex: string): [number, number, number] {
  let h = String(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 [0, 0, 0];
  return [
    parseInt(h.slice(0, 2), 16),
    parseInt(h.slice(2, 4), 16),
    parseInt(h.slice(4, 6), 16),
  ];
}

function clampByte(n: number): number {
  return Math.max(0, Math.min(255, Math.round(n)));
}

function rgbToHex(r: number, g: number, b: number): string {
  const t = (n: number) => clampByte(n).toString(16).padStart(2, '0');
  return `#${t(r)}${t(g)}${t(b)}`;
}

/** Convert hex → HSL. h ∈ [0,360), s/l ∈ [0,100]. Achromatic colors return h=0. */
export function hexToHsl(hex: string): [number, number, number] {
  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. Inputs are clamped: h wraps mod 360, s/l clamp to [0,100]. */
export function hslToHex(h: number, s: number, l: number): string {
  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;
  let g = 0;
  let 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), which holds the
 * base hue/saturation and spreads lightness across `count` steps.
 */
export function generatePalette(baseHex: string, scheme: Scheme, count = 5): string[] {
  const [h, s, l] = hexToHsl(baseHex);
  const base = hslToHex(h, s, l);
  const rot = (deg: number) => hslToHex(h + deg, s, l);
  switch (scheme) {
    case 'complement':
      return [base, rot(180)];
    case 'split-complement':
      return [base, rot(150), rot(210)];
    case 'analogous':
      return [rot(-30), base, rot(30)];
    case 'triadic':
      return [base, rot(120), rot(240)];
    case 'tetradic':
      return [base, rot(90), rot(180), rot(270)];
    case 'monochromatic': {
      const n = Math.max(1, Math.floor(count));
      const lo = Math.max(10, l - 32);
      const hi = Math.min(90, l + 32);
      const out: string[] = [];
      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: string, n: number): string[] {
  const [r, g, b] = hexToRgb(baseHex);
  const steps = Math.max(1, Math.floor(n));
  const out: string[] = [];
  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: string, n: number): string[] {
  const [r, g, b] = hexToRgb(baseHex);
  const steps = Math.max(1, Math.floor(n));
  const out: string[] = [];
  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;
}

/**
 * Pick the text color (black or white) with the higher contrast ratio against
 * `hex`, using WCAG relative luminance. The crossover is L ≈ 0.179 - where
 * black-on-color and white-on-color ratios are equal. Invalid input parses as
 * black (see hexToRgb) so it returns white text. Never throws.
 *
 * Vectors mirrored in cli/palette-generator/palette-generator_test.go.
 */
export function contrastText(hex: string): '#000000' | '#ffffff' {
  const [r8, g8, b8] = hexToRgb(hex);
  const channel = (c8: number) => {
    const c = c8 / 255;
    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  };
  const L = 0.2126 * channel(r8) + 0.7152 * channel(g8) + 0.0722 * channel(b8);
  return L > 0.179 ? '#000000' : '#ffffff';
}

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 →