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 →