Loading
Create a color palette tool that generates harmonious palettes using HSL manipulation, checks contrast ratios, and exports to CSS and Tailwind formats.
Color is one of the most impactful design decisions, but choosing colors that work well together requires understanding color theory. Complementary colors sit opposite on the color wheel, analogous colors sit adjacent, and triadic colors form an equilateral triangle. All of these relationships are trivially computed in HSL (Hue, Saturation, Lightness) color space — you just rotate the hue.
In this tutorial, you will build a color palette generator that creates harmonious palettes using HSL manipulation, supports multiple harmony types (complementary, analogous, triadic, split-complementary, tetradic), includes a WCAG contrast checker for accessibility, generates tint and shade scales, and exports palettes as CSS custom properties or Tailwind configuration. The tool runs entirely in the browser.
This project requires no build tools — just HTML, CSS, and JavaScript.
All code lives in three files: index.html, colors.js (the color engine), and app.js (the UI logic).
Create colors.js. HSL is the natural color space for palette generation because hue rotation produces visually harmonious results:
HSL separates the perceptual dimensions of color: hue is the "color" (red, blue, green), saturation is the intensity, and lightness is how bright or dark it is. This makes it trivial to generate tints (increase lightness), shades (decrease lightness), and harmonies (rotate hue).
Continue in colors.js:
The harmony algorithms come directly from color theory. Complementary colors (180 degrees apart) create maximum contrast. Analogous colors (30 degrees apart) create natural, pleasing combinations. Triadic (120 degrees) gives vibrant, balanced palettes.
Continue in colors.js:
The WCAG contrast ratio formula uses relative luminance, which weights green heavily (71.52%) because human eyes are most sensitive to green light. A ratio of 4.5:1 is the minimum for readable body text. This checker prevents you from creating beautiful but illegible color combinations.
Continue in colors.js:
Create index.html:
Create app.js:
Start a static server:
Open http://localhost:3007. Pick a base color using the color picker or type a hex value. Select different harmony types and observe how the palette changes. The color scales show tints and shades for each palette color. The contrast checker shows how each palette color looks against common background colors with WCAG compliance levels.
Key things to verify: complementary palettes show colors on opposite sides of the color wheel, analogous palettes show visually similar colors, the contrast checker flags low-contrast combinations as "Fail", clicking any swatch copies its hex value, and the export output is valid CSS or JSON.
Try starting with #6366f1 (indigo) and selecting triadic — you get a purple, an orange-gold, and a teal-green that work together. Switch to monochromatic for a single-hue scale that is excellent for data visualization. The contrast checker immediately shows which combinations meet accessibility standards, preventing you from shipping illegible text.
The export formats let you take palettes directly into your projects. The CSS export produces custom properties you can paste into your stylesheet. The Tailwind export produces a configuration block for your theme. From here, you could add random palette generation, color blindness simulation, a palette history, or integration with popular design tools via their APIs.
mkdir color-palette && cd color-palette/** Convert HSL to RGB. h in [0,360], s and l in [0,100]. */
export function hslToRgb(h, s, l) {
s /= 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 = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
};
}
/** Convert RGB to hex string. */
export function rgbToHex(r, g, b) {
return "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("");
}
/** Convert hex to RGB. */
export function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return null;
return {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
};
}
/** Convert RGB to HSL. Returns h in [0,360], s and l in [0,100]. */
export function rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
const l = (max + min) / 2;
if (d === 0) return { h: 0, s: 0, l: Math.round(l * 100) };
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
/** Convert hex to HSL. */
export function hexToHsl(hex) {
const rgb = hexToRgb(hex);
if (!rgb) return null;
return rgbToHsl(rgb.r, rgb.g, rgb.b);
}
/** Convert HSL to hex. */
export function hslToHex(h, s, l) {
const rgb = hslToRgb(h, s, l);
return rgbToHex(rgb.r, rgb.g, rgb.b);
}function normalizeHue(h) {
return ((h % 360) + 360) % 360;
}
export function generatePalette(baseHex, harmonyType) {
const hsl = hexToHsl(baseHex);
if (!hsl) return [];
const { h, s, l } = hsl;
let hues;
switch (harmonyType) {
case "complementary":
hues = [h, normalizeHue(h + 180)];
break;
case "analogous":
hues = [normalizeHue(h - 30), h, normalizeHue(h + 30)];
break;
case "triadic":
hues = [h, normalizeHue(h + 120), normalizeHue(h + 240)];
break;
case "split-complementary":
hues = [h, normalizeHue(h + 150), normalizeHue(h + 210)];
break;
case "tetradic":
hues = [h, normalizeHue(h + 90), normalizeHue(h + 180), normalizeHue(h + 270)];
break;
case "monochromatic":
return [
{ hex: hslToHex(h, s, Math.min(l + 30, 95)), hsl: { h, s, l: Math.min(l + 30, 95) } },
{ hex: hslToHex(h, s, Math.min(l + 15, 90)), hsl: { h, s, l: Math.min(l + 15, 90) } },
{ hex: hslToHex(h, s, l), hsl: { h, s, l } },
{ hex: hslToHex(h, s, Math.max(l - 15, 10)), hsl: { h, s, l: Math.max(l - 15, 10) } },
{ hex: hslToHex(h, s, Math.max(l - 30, 5)), hsl: { h, s, l: Math.max(l - 30, 5) } },
];
default:
hues = [h];
}
return hues.map((hue) => ({
hex: hslToHex(hue, s, l),
hsl: { h: hue, s, l },
}));
}
/** Generate a scale of tints and shades for a single color. */
export function generateScale(hex, steps = 10) {
const hsl = hexToHsl(hex);
if (!hsl) return [];
const { h, s } = hsl;
const scale = [];
for (let i = 0; i < steps; i++) {
const l = Math.round(95 - (i * 90) / (steps - 1));
scale.push({
step: (i + 1) * 100,
hex: hslToHex(h, s, l),
hsl: { h, s, l },
});
}
return scale;
}/**
* Calculate relative luminance per WCAG 2.1.
* Used for contrast ratio computation.
*/
export function relativeLuminance(hex) {
const rgb = hexToRgb(hex);
if (!rgb) return 0;
const [rs, gs, bs] = [rgb.r, rgb.g, rgb.b].map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
/**
* Calculate contrast ratio between two colors.
* WCAG AA requires 4.5:1 for normal text, 3:1 for large text.
* WCAG AAA requires 7:1 for normal text, 4.5:1 for large text.
*/
export function contrastRatio(hex1, hex2) {
const l1 = relativeLuminance(hex1);
const l2 = relativeLuminance(hex2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
export function wcagLevel(ratio) {
if (ratio >= 7) return "AAA";
if (ratio >= 4.5) return "AA";
if (ratio >= 3) return "AA Large";
return "Fail";
}export function exportAsCSS(palette, scaledColors) {
const lines = [":root {"];
palette.forEach((color, i) => {
lines.push(` --color-${i + 1}: ${color.hex};`);
});
for (const scaled of scaledColors) {
for (const step of scaled.scale) {
lines.push(` --color-${scaled.name}-${step.step}: ${step.hex};`);
}
}
lines.push("}");
return lines.join("\n");
}
export function exportAsTailwind(palette, scaledColors) {
const colors = {};
palette.forEach((color, i) => {
colors[`brand-${i + 1}`] = color.hex;
});
for (const scaled of scaledColors) {
colors[scaled.name] = {};
for (const step of scaled.scale) {
colors[scaled.name][step.step] = step.hex;
}
}
return `// Add to your Tailwind CSS theme\n// In globals.css @theme block (Tailwind v4)\n${JSON.stringify(colors, null, 2)}`;
}
export function exportAsJSON(palette) {
return JSON.stringify(
palette.map((c) => ({ hex: c.hex, hsl: c.hsl })),
null,
2
);
}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Color Palette Generator</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
background: #0a0a0f;
color: #f0f0f0;
padding: 24px;
max-width: 900px;
margin: 0 auto;
}
h1 {
font-size: 22px;
margin-bottom: 8px;
}
.subtitle {
color: #6b6b75;
font-size: 14px;
margin-bottom: 24px;
}
.controls {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
align-items: center;
}
.color-picker-wrapper {
position: relative;
}
.color-picker-wrapper input[type="color"] {
width: 48px;
height: 48px;
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
cursor: pointer;
background: none;
padding: 2px;
}
.hex-input {
padding: 10px 14px;
width: 120px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #f0f0f0;
font-family: "JetBrains Mono", monospace;
font-size: 14px;
}
select {
padding: 10px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #f0f0f0;
font-size: 14px;
cursor: pointer;
}
select option {
background: #151520;
}
.section {
margin-bottom: 32px;
}
.section h2 {
font-size: 16px;
margin-bottom: 12px;
}
.palette {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.swatch {
width: 120px;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: transform 150ms;
}
.swatch:hover {
transform: scale(1.02);
}
.swatch-color {
height: 80px;
}
.swatch-info {
padding: 8px;
background: rgba(255, 255, 255, 0.04);
font-size: 12px;
}
.swatch-info .hex {
font-family: "JetBrains Mono", monospace;
font-weight: 500;
}
.swatch-info .hsl {
color: #6b6b75;
font-size: 11px;
margin-top: 2px;
}
.scale-row {
display: flex;
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.scale-cell {
flex: 1;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-family: "JetBrains Mono", monospace;
cursor: pointer;
transition: transform 150ms;
position: relative;
}
.scale-cell:hover {
transform: scaleY(1.2);
z-index: 1;
}
.contrast-grid {
display: grid;
gap: 6px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.contrast-card {
padding: 12px;
border-radius: 8px;
font-size: 13px;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.contrast-card .ratio {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.contrast-card .level {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
display: inline-block;
}
.level-aaa {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.level-aa {
background: rgba(250, 204, 21, 0.15);
color: #fbbf24;
}
.level-aa-large {
background: rgba(251, 146, 60, 0.15);
color: #fb923c;
}
.level-fail {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.export-section {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.export-section button {
padding: 8px 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: transparent;
color: #a0a0a8;
cursor: pointer;
font-size: 13px;
}
.export-section button.active {
background: rgba(16, 185, 129, 0.1);
border-color: #10b981;
color: #10b981;
}
.export-output {
padding: 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
white-space: pre-wrap;
line-height: 1.6;
max-height: 300px;
overflow-y: auto;
}
.copy-hint {
font-size: 11px;
color: #6b6b75;
margin-top: 6px;
}
</style>
</head>
<body>
<h1>Color Palette Generator</h1>
<p class="subtitle">Generate harmonious color palettes with contrast checking</p>
<div class="controls">
<div class="color-picker-wrapper">
<input type="color" id="colorPicker" value="#10b981" />
</div>
<input type="text" class="hex-input" id="hexInput" value="#10b981" spellcheck="false" />
<select id="harmonySelect">
<option value="complementary">Complementary</option>
<option value="analogous">Analogous</option>
<option value="triadic">Triadic</option>
<option value="split-complementary">Split Complementary</option>
<option value="tetradic">Tetradic</option>
<option value="monochromatic">Monochromatic</option>
</select>
</div>
<div class="section">
<h2>Palette</h2>
<div class="palette" id="palette"></div>
</div>
<div class="section">
<h2>Color Scales</h2>
<div id="scales"></div>
</div>
<div class="section">
<h2>Contrast Checker</h2>
<div class="contrast-grid" id="contrastGrid"></div>
</div>
<div class="section">
<h2>Export</h2>
<div class="export-section" id="exportBtns"></div>
<div class="export-output" id="exportOutput"></div>
<p class="copy-hint">Click to copy to clipboard</p>
</div>
<script type="module" src="app.js"></script>
</body>
</html>import {
generatePalette,
generateScale,
contrastRatio,
wcagLevel,
hexToHsl,
hslToHex,
exportAsCSS,
exportAsTailwind,
exportAsJSON,
} from "./colors.js";
const colorPicker = document.getElementById("colorPicker");
const hexInput = document.getElementById("hexInput");
const harmonySelect = document.getElementById("harmonySelect");
const paletteEl = document.getElementById("palette");
const scalesEl = document.getElementById("scales");
const contrastGrid = document.getElementById("contrastGrid");
const exportBtns = document.getElementById("exportBtns");
const exportOutput = document.getElementById("exportOutput");
let currentPalette = [];
let currentScales = [];
let currentExport = "css";
function textColor(bgHex) {
const hsl = hexToHsl(bgHex);
return hsl && hsl.l > 55 ? "#000000" : "#ffffff";
}
function update() {
const baseColor = hexInput.value;
const harmony = harmonySelect.value;
currentPalette = generatePalette(baseColor, harmony);
currentScales = currentPalette.map((color, i) => ({
name: `palette-${i + 1}`,
scale: generateScale(color.hex),
}));
renderPalette();
renderScales();
renderContrast();
renderExport();
}
function renderPalette() {
paletteEl.innerHTML = currentPalette
.map(
(color) => `
<div class="swatch" data-hex="${color.hex}">
<div class="swatch-color" style="background:${color.hex}"></div>
<div class="swatch-info">
<div class="hex">${color.hex}</div>
<div class="hsl">hsl(${color.hsl.h}, ${color.hsl.s}%, ${color.hsl.l}%)</div>
</div>
</div>`
)
.join("");
paletteEl.querySelectorAll(".swatch").forEach((swatch) => {
swatch.addEventListener("click", () => {
navigator.clipboard.writeText(swatch.dataset.hex);
});
});
}
function renderScales() {
scalesEl.innerHTML = currentScales
.map(
(scaled) => `
<div class="scale-row">
${scaled.scale
.map(
(step) => `
<div class="scale-cell" style="background:${step.hex};color:${textColor(step.hex)}" title="${step.hex}" data-hex="${step.hex}">
${step.step}
</div>`
)
.join("")}
</div>`
)
.join("");
scalesEl.querySelectorAll(".scale-cell").forEach((cell) => {
cell.addEventListener("click", () => {
navigator.clipboard.writeText(cell.dataset.hex);
});
});
}
function renderContrast() {
const backgrounds = ["#ffffff", "#f0f0f0", "#0a0a0f", "#1a1a2e"];
const cards = [];
for (const color of currentPalette) {
for (const bg of backgrounds) {
const ratio = contrastRatio(color.hex, bg);
const level = wcagLevel(ratio);
const levelClass =
level === "AAA"
? "level-aaa"
: level === "AA"
? "level-aa"
: level === "AA Large"
? "level-aa-large"
: "level-fail";
cards.push(`
<div class="contrast-card" style="background:${bg};color:${color.hex}">
<div class="ratio">${ratio.toFixed(2)}:1</div>
<span class="level ${levelClass}">${level}</span>
<div style="margin-top:6px;font-size:11px;opacity:0.6;">${color.hex} on ${bg}</div>
</div>`);
}
}
contrastGrid.innerHTML = cards.join("");
}
function renderExport() {
exportBtns.innerHTML = ["css", "tailwind", "json"]
.map(
(fmt) =>
`<button class="${fmt === currentExport ? "active" : ""}" data-fmt="${fmt}">${fmt.toUpperCase()}</button>`
)
.join("");
exportBtns.querySelectorAll("button").forEach((btn) => {
btn.addEventListener("click", () => {
currentExport = btn.dataset.fmt;
renderExport();
});
});
let output;
switch (currentExport) {
case "css":
output = exportAsCSS(currentPalette, currentScales);
break;
case "tailwind":
output = exportAsTailwind(currentPalette, currentScales);
break;
case "json":
output = exportAsJSON(currentPalette);
break;
default:
output = "";
}
exportOutput.textContent = output;
exportOutput.onclick = () => navigator.clipboard.writeText(output);
}
colorPicker.addEventListener("input", () => {
hexInput.value = colorPicker.value;
update();
});
hexInput.addEventListener("input", () => {
const val = hexInput.value;
if (/^#[0-9a-f]{6}$/i.test(val)) {
colorPicker.value = val;
update();
}
});
harmonySelect.addEventListener("change", update);
// Initial render
update();npx serve . -p 3007