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 →