TL;DR
A color palette generator creates harmonious color sets from a base hue using color theory rules β complementary (180Β°), triadic (120Β°), tetradic (90Β°) β combined with tint/shade scales (50β900). Use HSL for algorithmic generation, OKLCH for perceptually uniform CSS mixing, and chroma.js for advanced JavaScript palette logic. Always validate contrast ratios against WCAG AA (4.5:1) for accessible designs. Try our online color picker to build and export palettes instantly.
Color Models: RGB, HSL, HSV/HSB, and HEX Conversions
Every color on screen is ultimately stored as RGB triplets, but different color models serve different purposes. Choosing the right model for algorithmic palette generation dramatically simplifies the math.
Model Comparison
| Model | Components | Best For |
|---|---|---|
RGB | R 0-255, G 0-255, B 0-255 | Screen rendering, pixel manipulation |
HEX | #RRGGBB or #RGB shorthand | CSS, design tokens, storage |
HSL | H 0-360Β°, S 0-100%, L 0-100% | Color schemes, tint/shade generation |
HSV/HSB | H 0-360Β°, S 0-100%, V 0-100% | Color pickers, image editing |
OKLCH | L 0-1, C 0-0.4, H 0-360Β° | Perceptually uniform CSS mixing |
// ββ HEX β RGB Conversions ββββββββββββββββββββββββββββββββββββββββββ
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const clean = hex.replace('#', '');
// Expand 3-char shorthand #RGB β #RRGGBB
const full = clean.length === 3
? clean.split('').map(c => c + c).join('')
: clean;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
};
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b]
.map(v => Math.round(v).toString(16).padStart(2, '0'))
.join('');
}
// ββ RGB β HSL Conversions ββββββββββββββββββββββββββββββββββββββββββ
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
let h = 0, s = 0;
const l = (max + min) / 2;
if (delta !== 0) {
s = delta / (1 - Math.abs(2 * l - 1));
switch (max) {
case rn: h = ((gn - bn) / delta + 6) % 6; break;
case gn: h = (bn - rn) / delta + 2; break;
case bn: h = (rn - gn) / delta + 4; break;
}
h = Math.round(h * 60);
}
return { h, s: Math.round(s * 100), l: Math.round(l * 100) };
}
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
const sn = s / 100, ln = l / 100;
const a = sn * Math.min(ln, 1 - ln);
const f = (n: number) => {
const k = (n + h / 30) % 12;
return ln - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255) };
}
// ββ RGB β HSV Conversions ββββββββββββββββββββββββββββββββββββββββββ
function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
let h = 0;
const s = max === 0 ? 0 : delta / max;
const v = max;
if (delta !== 0) {
switch (max) {
case rn: h = ((gn - bn) / delta + 6) % 6; break;
case gn: h = (bn - rn) / delta + 2; break;
case bn: h = (rn - gn) / delta + 4; break;
}
h = Math.round(h * 60);
}
return { h, s: Math.round(s * 100), v: Math.round(v * 100) };
}
// ββ Usage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const rgb = hexToRgb('#3b82f6'); // { r: 59, g: 130, b: 246 }
const hsl = rgbToHsl(59, 130, 246); // { h: 217, s: 91, l: 60 }
const back = hslToRgb(217, 91, 60); // { r: 59, g: 130, b: 246 }
console.log(rgbToHex(back.r, back.g, back.b)); // "#3b82f6"CSS Custom Properties: Design Tokens and Color Schemes
CSS custom properties (variables) are the foundation of modern design systems. Defining colors as tokens in :root enables consistent theming, one-line dark mode switches, and runtime updates without rebuilding the stylesheet.
The recommended pattern for design tokens combines semantic naming (what the color means) with HSL values stored as raw channel numbers β this unlocks dynamic alpha channels using the CSS hsl(var(--color-primary) / 0.5) syntax.
/* ββ Design Token Architecture βββββββββββββββββββββββββββββββββββββββ
Store raw HSL channels (no units) for alpha compositing:
Usage: hsl(var(--color-primary) / 0.5) β 50% transparent primary
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
:root {
/* ββ Brand Palette (base hue: 217Β° β blue) ββ */
--color-primary-h: 217;
--color-primary-s: 91%;
--color-primary-l: 60%;
--color-primary: var(--color-primary-h) var(--color-primary-s) var(--color-primary-l);
/* ββ Semantic Tokens ββ */
--bg-base: hsl(0 0% 100%);
--bg-subtle: hsl(210 20% 98%);
--bg-muted: hsl(210 16% 93%);
--text-primary: hsl(215 25% 15%);
--text-secondary:hsl(215 16% 40%);
--text-muted: hsl(215 12% 60%);
--border-subtle: hsl(215 16% 90%);
--border-strong: hsl(215 16% 75%);
/* ββ Interactive State Tokens ββ */
--color-primary-hover: hsl(var(--color-primary-h) var(--color-primary-s) 52%);
--color-primary-active: hsl(var(--color-primary-h) var(--color-primary-s) 44%);
--color-primary-subtle: hsl(var(--color-primary-h) var(--color-primary-s) 95%);
/* ββ Semantic Status Colors ββ */
--color-success: hsl(142 72% 29%);
--color-warning: hsl(38 92% 50%);
--color-error: hsl(0 84% 60%);
--color-info: hsl(199 89% 48%);
/* ββ Spacing & Radius Tokens (non-color, but part of the design system) ββ */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
}
/* ββ Component Usage βββββββββββββββββββββββββββββββββββββββββββββββββ */
.btn-primary {
background-color: hsl(var(--color-primary));
color: hsl(0 0% 100%);
border: 1px solid transparent;
border-radius: var(--radius-md);
transition: background-color 150ms ease;
}
.btn-primary:hover { background-color: var(--color-primary-hover); }
.btn-primary:active { background-color: var(--color-primary-active); }
/* ββ Alpha Variant (same hue, transparent) ββββββββββββββββββββββββ */
.btn-ghost {
background-color: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
border: 1px solid hsl(var(--color-primary) / 0.3);
}
/* ββ Programmatic Updates via JavaScript ββββββββββββββββββββββββββ */
/* Change entire theme by swapping --color-primary-h:
document.documentElement.style.setProperty('--color-primary-h', '270');
β Instantly shifts all primary tokens from blue to purple */Generating Complementary, Triadic, Tetradic, and Split-Complementary Colors
Color harmony schemes are all derived from rotating the hue angle in HSL space. Because hue is cyclic (0Β° = 360Β°), use the modulo operator to keep values in range. Saturation and lightness stay constant to maintain visual balance.
// ββ Color Harmony Generator ββββββββββββββββββββββββββββββββββββββ
interface HslColor { h: number; s: number; l: number }
interface ColorScheme { name: string; colors: HslColor[] }
function rotateHue(base: HslColor, degrees: number): HslColor {
return { ...base, h: (base.h + degrees + 360) % 360 };
}
function hslToCss({ h, s, l }: HslColor): string {
return `hsl(${h}, ${s}%, ${l}%)`;
}
// ββ Complementary: 1 base + 1 opposite (180Β°) βββββββββββββββββββββ
function complementary(base: HslColor): ColorScheme {
return {
name: 'Complementary',
colors: [base, rotateHue(base, 180)],
};
}
// ββ Triadic: 3 colors evenly spaced at 120Β° ββββββββββββββββββββββ
function triadic(base: HslColor): ColorScheme {
return {
name: 'Triadic',
colors: [base, rotateHue(base, 120), rotateHue(base, 240)],
};
}
// ββ Tetradic (Square): 4 colors at 90Β° intervals βββββββββββββββββ
function tetradic(base: HslColor): ColorScheme {
return {
name: 'Tetradic',
colors: [
base,
rotateHue(base, 90),
rotateHue(base, 180),
rotateHue(base, 270),
],
};
}
// ββ Split-Complementary: base + 2 colors near complement βββββββββ
function splitComplementary(base: HslColor): ColorScheme {
return {
name: 'Split-Complementary',
colors: [base, rotateHue(base, 150), rotateHue(base, 210)],
};
}
// ββ Analogous: 5 colors within Β±30Β° (harmonious, low contrast) βββ
function analogous(base: HslColor, count = 5, spread = 30): ColorScheme {
const step = (spread * 2) / (count - 1);
return {
name: 'Analogous',
colors: Array.from({ length: count }, (_, i) =>
rotateHue(base, -spread + i * step)
),
};
}
// ββ Example usage ββββββββββββββββββββββββββββββββββββββββββββββββββ
const base: HslColor = { h: 217, s: 91, l: 60 }; // blue
const comp = complementary(base); // blue + orange
const tri = triadic(base); // blue + red + green
const tet = tetradic(base); // blue + purple + orange + yellow
const splitC = splitComplementary(base); // blue + red-orange + yellow-orange
const analog = analogous(base, 5, 30); // blue family Β±30Β°
// Print CSS values
tri.colors.forEach(c => console.log(hslToCss(c)));
// hsl(217, 91%, 60%)
// hsl(337, 91%, 60%)
// hsl(97, 91%, 60%)Tint, Shade, and Tone β Generating a 50β900 Scale Like Tailwind
A tint mixes the color with white (increase lightness), a shade mixes with black (decrease lightness), and a tone mixes with gray (decrease saturation). Tailwind CSS uses a 10-step scale (50, 100, 200, β¦ 900) where 500 is the base color.
// ββ Tailwind-style 50-900 scale generator ββββββββββββββββββββββββ
// 500 = base color; lighter values β tints; darker β shades
type ColorScale = Record<number, string>;
function generateScale(baseH: number, baseS: number, baseL: number): ColorScale {
// Lightness map: key = Tailwind step, value = target lightness %
const lightnessMap: Record<number, number> = {
50: 97,
100: 94,
200: 87,
300: 77,
400: 67,
500: baseL, // β base color
600: Math.max(baseL - 10, 20),
700: Math.max(baseL - 22, 14),
800: Math.max(baseL - 32, 10),
900: Math.max(baseL - 42, 7),
};
// Saturation boost for mid-range, reduction for extremes
const saturationMap: Record<number, number> = {
50: Math.round(baseS * 0.20),
100: Math.round(baseS * 0.35),
200: Math.round(baseS * 0.55),
300: Math.round(baseS * 0.75),
400: Math.round(baseS * 0.90),
500: baseS,
600: Math.min(baseS + 5, 100),
700: Math.min(baseS + 8, 100),
800: Math.min(baseS + 6, 100),
900: Math.min(baseS + 4, 100),
};
const scale: ColorScale = {};
for (const step of [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]) {
scale[step] = `hsl(${baseH}, ${saturationMap[step]}%, ${lightnessMap[step]}%)`;
}
return scale;
}
// ββ Generate blue-500 (#3b82f6) scale βββββββββββββββββββββββββββββ
const blueScale = generateScale(217, 91, 60);
console.log(blueScale);
// {
// 50: "hsl(217, 18%, 97%)", β very light blue-white
// 100: "hsl(217, 32%, 94%)",
// 200: "hsl(217, 50%, 87%)",
// 300: "hsl(217, 68%, 77%)",
// 400: "hsl(217, 82%, 67%)",
// 500: "hsl(217, 91%, 60%)", β base
// 600: "hsl(217, 96%, 50%)",
// 700: "hsl(217, 99%, 38%)",
// 800: "hsl(217, 97%, 28%)",
// 900: "hsl(217, 95%, 18%)", β very dark blue
// }
// ββ CSS Custom Properties output ββββββββββββββββββββββββββββββββββ
function scaleToCssVars(name: string, scale: ColorScale): string {
return Object.entries(scale)
.map(([step, value]) => ` --color-${name}-${step}: ${value};`)
.join('\n');
}
console.log(`:root {\n${scaleToCssVars('blue', blueScale)}\n}`);Color Contrast and Accessibility β WCAG AA/AAA Compliance
The WCAG 2.1 contrast ratio formula compares the relative luminance of two colors. Luminance is computed by first linearizing sRGB values (removing gamma encoding), then combining channels with human-vision weights (green contributes most to perceived brightness).
// ββ WCAG 2.1 Contrast Ratio Calculator βββββββββββββββββββββββββββ
/** Convert sRGB 0-255 channel to linear light value */
function linearize(channel: number): number {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
/** Relative luminance: 0 (black) β 1 (white) */
function relativeLuminance(r: number, g: number, b: number): number {
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}
/** WCAG contrast ratio: range 1:1 (identical) β 21:1 (black/white) */
function contrastRatio(hex1: string, hex2: string): number {
const c1 = hexToRgb(hex1);
const c2 = hexToRgb(hex2);
const L1 = relativeLuminance(c1.r, c1.g, c1.b);
const L2 = relativeLuminance(c2.r, c2.g, c2.b);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
type WcagLevel = 'AAA' | 'AA' | 'AA Large' | 'Fail';
function wcagLevel(ratio: number, isLargeText = false): WcagLevel {
if (ratio >= 7) return 'AAA';
if (ratio >= 4.5) return 'AA';
if (ratio >= 3 && isLargeText) return 'AA Large';
return 'Fail';
}
// ββ Accessibility audit for a color pair βββββββββββββββββββββββββ
function auditColors(foreground: string, background: string) {
const ratio = contrastRatio(foreground, background);
return {
ratio: parseFloat(ratio.toFixed(2)),
normalText: wcagLevel(ratio, false),
largeText: wcagLevel(ratio, true),
passes: ratio >= 4.5,
};
}
// ββ Safe color combinations (contrast β₯ 4.5:1) βββββββββββββββββββ
const palette = {
blue500: '#3b82f6',
blue700: '#1d4ed8',
blue900: '#1e3a8a',
white: '#ffffff',
gray900: '#111827',
gray100: '#f3f4f6',
};
console.log(auditColors(palette.white, palette.blue700));
// { ratio: 6.84, normalText: 'AA', largeText: 'AAA', passes: true }
console.log(auditColors(palette.blue500, palette.white));
// { ratio: 3.07, normalText: 'Fail', largeText: 'AA Large', passes: false }
// β blue-500 on white fails AA for normal text! Use blue-600+ instead.
console.log(auditColors(palette.gray900, palette.gray100));
// { ratio: 15.32, normalText: 'AAA', largeText: 'AAA', passes: true }
// ββ Find the darkest accessible shade for a given background ββββββ
function findAccessibleShade(
scale: ColorScale,
background: string,
minRatio = 4.5
): string | null {
for (const step of [900, 800, 700, 600, 500, 400]) {
const ratio = contrastRatio(scale[step], background);
if (ratio >= minRatio) return scale[step];
}
return null;
}CSS color-mix(): Native Color Mixing with OKLCH and P3 Gamut
The color-mix() function, supported in all modern browsers since mid-2023 (Chrome 111, Firefox 113, Safari 16.2), enables native CSS color operations without JavaScript. The key decision is the color space used for interpolation β it significantly affects the midpoint color.
OKLCH is the recommended color space: it is perceptually uniform (equal numeric changes produce equal visual changes), avoids the gray mud problem of RGB mixing, and supports the wide-gamut P3 display gamut.
/* ββ CSS color-mix() Syntax ββββββββββββββββββββββββββββββββββββββββββ
color-mix(in <color-space>, <color1> <percentage>, <color2>)
If percentages omit, they split 50/50.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
:root {
--brand: oklch(62% 0.19 250); /* vivid blue */
--accent: oklch(70% 0.22 140); /* vivid green */
--surface: oklch(99% 0.00 0); /* near-white */
/* ββ Tints: mix brand with white at different ratios ββ */
--brand-50: color-mix(in oklch, var(--brand) 5%, white);
--brand-100: color-mix(in oklch, var(--brand) 15%, white);
--brand-200: color-mix(in oklch, var(--brand) 30%, white);
--brand-300: color-mix(in oklch, var(--brand) 50%, white);
--brand-400: color-mix(in oklch, var(--brand) 70%, white);
/* 500 = base */
--brand-600: color-mix(in oklch, var(--brand), black 15%);
--brand-700: color-mix(in oklch, var(--brand), black 30%);
--brand-800: color-mix(in oklch, var(--brand), black 50%);
--brand-900: color-mix(in oklch, var(--brand), black 70%);
/* ββ Complementary color via hue rotation ββ */
/* oklch hue is perceptual, not geometric β use chroma.js for precise 180Β° */
--complement: oklch(from var(--brand) l c calc(h + 180));
/* ββ Transparency variants ββ */
--brand-ghost: color-mix(in oklch, var(--brand) 10%, transparent);
--brand-subtle: color-mix(in oklch, var(--brand) 20%, var(--surface));
}
/* ββ P3 Wide-Gamut Colors (requires @media (color-gamut: p3)) βββββββ */
@supports (color: color(display-p3 0 0 1)) {
:root {
/* P3 can express more saturated colors than sRGB */
--vivid-blue: color(display-p3 0.10 0.45 0.98);
--vivid-red: color(display-p3 0.95 0.10 0.15);
}
}
/* ββ Fallback pattern for older browsers ββββββββββββββββββββββββββ */
.btn {
background: #3b82f6; /* sRGB fallback */
background: var(--brand); /* OKLCH with @supports */
}
/* ββ Dark mode variant using color-mix ββββββββββββββββββββββββββββ */
@media (prefers-color-scheme: dark) {
:root {
/* Lighten brand for dark backgrounds */
--brand-text: color-mix(in oklch, var(--brand) 70%, white);
--bg-base: oklch(18% 0.01 250);
--bg-subtle: color-mix(in oklch, var(--bg-base), white 5%);
}
}JavaScript chroma.js: Scale Generation, Bezier Interpolation, and Brewer Palettes
chroma.js is a 13KB JavaScript library (no dependencies) that handles color space conversions, palette generation, and perceptually smooth gradients. Install with npm install chroma-js and optionally npm install @types/chroma-js for TypeScript.
// npm install chroma-js @types/chroma-js
import chroma from 'chroma-js';
// ββ Basic Color Manipulation βββββββββββββββββββββββββββββββββββββββ
const blue = chroma('#3b82f6');
console.log(blue.hex()); // "#3b82f6"
console.log(blue.rgb()); // [59, 130, 246]
console.log(blue.hsl()); // [0.6028, 0.8947, 0.5980]
console.log(blue.lab()); // [52.27, 4.39, -51.84]
console.log(blue.lch()); // [52.27, 51.85, 274.84]
// ββ Lightness / Darkness βββββββββββββββββββββββββββββββββββββββββββ
const lighter = blue.brighten(1).hex(); // "#7eaff9"
const darker = blue.darken(1).hex(); // "#145bb2"
const tinted = blue.mix('white', 0.5, 'lch').hex(); // perceptual mix
// ββ Scale Generation (10-step palette) βββββββββββββββββββββββββββ
const blueScale = chroma.scale(['#dbeafe', '#3b82f6', '#1e3a8a'])
.mode('lch') // perceptually uniform interpolation
.colors(10); // returns array of 10 hex strings
// ['#dbeafe', '#a5c7f8', '#6aa0f3', '#3f7de8', '#2563d7', ...]
// ββ Bezier Interpolation (smoother than linear) βββββββββββββββββββ
const smooth = chroma.bezier(['#f0f9ff', '#3b82f6', '#1e3a8a'])
.scale()
.colors(9);
// Bezier interpolation avoids the "muddy middle" problem
// ββ ColorBrewer Palettes (for data visualization) βββββββββββββββββ
const sequential = chroma.brewer.Blues; // 9 blues for choropleth maps
const diverging = chroma.brewer.RdBu; // red-white-blue diverging
const qualitative = chroma.brewer.Set3; // 12 distinct categorical colors
console.log(sequential); // ['#f7fbff', '#deebf7', '#c6dbef', ...]
// ββ Contrast Checking βββββββββββββββββββββββββββββββββββββββββββββ
const ratio = chroma.contrast('#3b82f6', '#ffffff');
console.log(ratio); // 3.07 β fails WCAG AA for normal text
// Find the darkest blue that passes AA on white
const blueShades = blueScale.filter(hex => chroma.contrast(hex, '#fff') >= 4.5);
console.log(blueShades[0]); // First shade meeting AA requirement
// ββ Generate Tailwind-compatible palette object βββββββββββββββββββ
function generateTailwindPalette(baseHex: string): Record<number, string> {
const base = chroma(baseHex);
const scale = chroma.scale([
base.brighten(3).desaturate(0.5).hex(), // 50
baseHex, // 500
base.darken(3).hex(), // 900
]).mode('lch').colors(10);
const steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
return Object.fromEntries(steps.map((step, i) => [step, scale[i]]));
}
const myBlue = generateTailwindPalette('#3b82f6');
// { 50: '#eff6ff', 100: '#dbeafe', ..., 900: '#1e3a8a' }
// ββ Multi-hue Gradient for Data Visualization βββββββββββββββββββββ
const heatmap = chroma.scale('YlOrRd').domain([0, 100]);
const getHeatColor = (value: number) => heatmap(value).hex();
console.log(getHeatColor(0)); // '#ffffcc' (yellow)
console.log(getHeatColor(50)); // '#fd8d3c' (orange)
console.log(getHeatColor(100)); // '#800026' (dark red)Python colorsys Module + Pillow: Extract Dominant Colors from Images
Python's standard library colorsys module provides conversion functions between RGB, HLS, HSV, and YIQ. The Pillow (PIL fork) library handles image loading and pixel data extraction. Combining them with sklearn's K-means algorithm extracts a dominant color palette from any image.
# pip install Pillow scikit-learn numpy
import colorsys
import numpy as np
from PIL import Image
from sklearn.cluster import KMeans
# ββ 1. colorsys: Standard-library Color Conversions ββββββββββββββ
# All colorsys values are normalized 0.0β1.0
rgb = (0.231, 0.510, 0.965) # = #3b82f6
# RGB β HLS (note: Hue-Lightness-Saturation order, not H-S-L)
h, l, s = colorsys.rgb_to_hls(*rgb) # (0.603, 0.598, 0.895)
print(f"Hue: {h*360:.1f}Β°, Saturation: {s*100:.1f}%, Lightness: {l*100:.1f}%")
# RGB β HSV
h, s, v = colorsys.rgb_to_hsv(*rgb) # (0.603, 0.760, 0.965)
print(f"Hue: {h*360:.1f}Β°, Saturation: {s*100:.1f}%, Value: {v*100:.1f}%")
# HLS β RGB (back-conversion)
r, g, b = colorsys.hls_to_rgb(h, l, s)
hex_out = '#{:02x}{:02x}{:02x}'.format(int(r*255), int(g*255), int(b*255))
print(hex_out) # "#3b82f6"
# ββ 2. Pillow: Load Image and Extract Pixel Data ββββββββββββββββββ
def extract_dominant_colors(image_path: str, n_colors: int = 6) -> list[str]:
"""Return n_colors dominant HEX colors from an image using K-means."""
img = Image.open(image_path).convert('RGB')
# Resize for performance (150Γ150 = max 22,500 pixels to cluster)
img.thumbnail((150, 150), Image.LANCZOS)
# Flatten to (N, 3) array of RGB pixels
pixels = np.array(img).reshape(-1, 3).astype(float)
# Remove near-white and near-black pixels (they dominate but aren't interesting)
mask = (pixels.sum(axis=1) > 50) & (pixels.sum(axis=1) < 700)
pixels = pixels[mask]
if len(pixels) < n_colors:
return []
# K-means clustering to find dominant color centers
kmeans = KMeans(n_clusters=n_colors, random_state=42, n_init='auto')
kmeans.fit(pixels)
# Sort by cluster size (most dominant first)
centers = kmeans.cluster_centers_.astype(int)
counts = np.bincount(kmeans.labels_)
order = np.argsort(-counts)
return [
'#{:02x}{:02x}{:02x}'.format(*centers[i])
for i in order
]
# Usage
palette = extract_dominant_colors('photo.jpg', n_colors=5)
print(palette) # ['#2a4a7a', '#d4a853', '#8cb87f', '#e8dfc9', '#1a2d4b']
# ββ 3. Using colorthief (wraps K-means, simpler API) ββββββββββββββ
# pip install colorthief
from colorthief import ColorThief
ct = ColorThief('photo.jpg')
dominant = ct.get_color(quality=1) # (42, 74, 122) RGB tuple
palette2 = ct.get_palette(color_count=6) # list of 6 RGB tuples
hex_palette = ['#{:02x}{:02x}{:02x}'.format(*c) for c in palette2]
print(hex_palette)
# ββ 4. Generate Color Scale from Extracted Color βββββββββββββββββ
def generate_scale(r: int, g: int, b: int) -> dict:
"""Generate a 9-step tint/shade scale (100-900) from an RGB color."""
h, l, s = colorsys.rgb_to_hls(r/255, g/255, b/255)
scale = {}
lightness_steps = [0.95, 0.88, 0.78, 0.68, l, max(l-0.12,0.08), max(l-0.24,0.06), max(l-0.38,0.04), max(l-0.50,0.02)]
for i, (step, lt) in enumerate(zip([100,200,300,400,500,600,700,800,900], lightness_steps)):
rr, gg, bb = colorsys.hls_to_rgb(h, lt, s)
scale[step] = '#{:02x}{:02x}{:02x}'.format(int(rr*255), int(gg*255), int(bb*255))
return scale
print(generate_scale(59, 130, 246))
# {100: '#dde8fd', 200: '#b7cefc', ..., 900: '#0d1e3d'}Tailwind CSS Color Configuration β Extending with Custom Palettes
Tailwind's color system is configured in tailwind.config.js (or tailwind.config.ts). Use theme.extend.colors to add custom palettes while keeping Tailwind defaults. Use theme.colors to replace them entirely.
For runtime-switchable themes (dark mode, white-labeling), use CSS variable references in the config instead of raw color values. This pattern generates Tailwind utility classes that read from CSS variables, enabling theme changes without a rebuild.
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
darkMode: 'class', // or 'media'
theme: {
extend: {
colors: {
// ββ Static custom palette ββββββββββββββββββββββββββββββββββ
brand: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6', // β base
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
// ββ CSS-variable-backed palette (runtime theming) ββββββββββ
// In your CSS: --color-primary: 217 91% 60%;
// Usage: bg-primary (maps to hsl(var(--color-primary)))
primary: {
DEFAULT: 'hsl(var(--color-primary) / <alpha-value>)',
hover: 'hsl(var(--color-primary-hover) / <alpha-value>)',
subtle: 'hsl(var(--color-primary-subtle) / <alpha-value>)',
},
// Semantic tokens
surface: {
DEFAULT: 'hsl(var(--bg-base) / <alpha-value>)',
subtle: 'hsl(var(--bg-subtle) / <alpha-value>)',
muted: 'hsl(var(--bg-muted) / <alpha-value>)',
},
'content-primary': 'hsl(var(--text-primary) / <alpha-value>)',
'content-secondary': 'hsl(var(--text-secondary) / <alpha-value>)',
},
// ββ Custom Border Radius Tokens ββββββββββββββββββββββββββββββββ
borderRadius: {
DEFAULT: '0.5rem',
lg: '0.75rem',
xl: '1rem',
},
},
},
plugins: [],
};
export default config;
/* ββ Usage in components ββββββββββββββββββββββββββββββββββββββββββββββ
<button className="bg-brand-500 hover:bg-brand-600 text-white rounded">
Static palette - Tailwind resolves at build time
</button>
<button className="bg-primary text-white hover:bg-primary-hover">
CSS variable palette - resolves at runtime
</button>
<div className="bg-brand-50 text-brand-900 dark:bg-brand-900 dark:text-brand-50">
Automatic dark mode inversion using Tailwind dark: modifier
</div>
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
// ββ Script: generate tailwind colors from chroma.js scale βββββββββ
// (Run in Node.js to auto-generate config)
import chroma from 'chroma-js';
function chromaToTailwindShades(baseHex: string): Record<number, string> {
const scale = chroma.scale([
chroma(baseHex).brighten(3).desaturate(0.8).hex(),
baseHex,
chroma(baseHex).darken(3).hex(),
]).mode('lch').colors(10);
const steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
return Object.fromEntries(steps.map((step, i) => [step, scale[i]]));
}
// Output: copy-paste into tailwind.config.ts
const result = chromaToTailwindShades('#7c3aed'); // violet-600
console.log(JSON.stringify(result, null, 2));Dark Mode Palettes: Light/Dark Token Pairs and HSL-Based Theming Strategy
Effective dark mode is not simply inverting colors β it requires a separate set of design tokens optimized for dark backgrounds. The key principles are: reduce saturation slightly (vivid colors appear garish on dark backgrounds), increase lightness of interactive elements (they need to stand out without hurting eyes), and use surface layering (not just one background color).
The recommended architecture uses prefers-color-scheme as the default with a .dark class override, so users can toggle manually while respecting system preference by default.
/* ββ Complete Light/Dark Token System ββββββββββββββββββββββββββββββββ
Architecture:
1. Define primitive palette (50-900 scale) once
2. Map semantic tokens to primitives per theme
3. Components only reference semantic tokens
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
/* ββ Primitive Palette (shared, theme-independent) βββββββββββββββ */
:root {
/* Blue scale */
--blue-50: hsl(214, 100%, 97%);
--blue-100: hsl(214, 95%, 93%);
--blue-200: hsl(213, 94%, 87%);
--blue-400: hsl(213, 94%, 68%);
--blue-500: hsl(217, 91%, 60%);
--blue-600: hsl(221, 83%, 53%);
--blue-700: hsl(224, 76%, 48%);
--blue-800: hsl(226, 71%, 40%);
--blue-900: hsl(224, 64%, 33%);
/* Gray scale */
--gray-0: hsl(0, 0%, 100%);
--gray-50: hsl(210, 20%, 98%);
--gray-100: hsl(220, 14%, 96%);
--gray-200: hsl(220, 13%, 91%);
--gray-300: hsl(216, 12%, 84%);
--gray-400: hsl(218, 11%, 65%);
--gray-500: hsl(220, 9%, 46%);
--gray-600: hsl(215, 14%, 34%);
--gray-700: hsl(217, 19%, 27%);
--gray-800: hsl(215, 28%, 17%);
--gray-850: hsl(220, 30%, 13%);
--gray-900: hsl(222, 33%, 11%);
--gray-950: hsl(224, 39%, 7%);
}
/* ββ Light Mode Semantic Tokens ββββββββββββββββββββββββββββββββββ */
:root,
.light {
/* Backgrounds */
--bg-canvas: var(--gray-0); /* Page background */
--bg-base: var(--gray-50); /* Main content area */
--bg-subtle: var(--gray-100); /* Subtle sections */
--bg-muted: var(--gray-200); /* Disabled, muted areas */
--bg-overlay: rgba(0, 0, 0, 0.5); /* Modals, drawers */
/* Text */
--text-primary: var(--gray-900);
--text-secondary: var(--gray-600);
--text-tertiary: var(--gray-400);
--text-disabled: var(--gray-300);
--text-inverse: var(--gray-0);
--text-link: var(--blue-600);
--text-link-hover:var(--blue-700);
/* Borders */
--border-subtle: var(--gray-200);
--border-base: var(--gray-300);
--border-strong: var(--gray-400);
--border-focus: var(--blue-500);
/* Interactive */
--bg-interactive: var(--blue-600);
--bg-interactive-hover: var(--blue-700);
--bg-interactive-subtle: var(--blue-50);
--text-on-interactive: var(--gray-0);
/* Status */
--status-success-bg: hsl(142, 76%, 94%);
--status-success-text: hsl(142, 72%, 29%);
--status-error-bg: hsl(0, 93%, 94%);
--status-error-text: hsl(0, 72%, 51%);
--status-warning-bg: hsl(38, 100%, 93%);
--status-warning-text: hsl(25, 95%, 43%);
}
/* ββ Dark Mode Semantic Tokens βββββββββββββββββββββββββββββββββββ */
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
.dark,
@media (prefers-color-scheme: dark) {
:not([data-theme='light']) {
/* Backgrounds β layered elevation model */
--bg-canvas: var(--gray-950); /* Base layer */
--bg-base: var(--gray-900); /* Cards, content */
--bg-subtle: var(--gray-850); /* Side panels */
--bg-muted: var(--gray-800); /* Inputs, table rows */
--bg-overlay: rgba(0, 0, 0, 0.7);
/* Text β reduced contrast to prevent eye strain */
--text-primary: var(--gray-50);
--text-secondary: var(--gray-400);
--text-tertiary: var(--gray-500);
--text-disabled: var(--gray-600);
--text-inverse: var(--gray-900);
--text-link: var(--blue-400); /* Lighter in dark mode */
--text-link-hover:var(--blue-300);
/* Borders */
--border-subtle: var(--gray-800);
--border-base: var(--gray-700);
--border-strong: var(--gray-600);
--border-focus: var(--blue-400);
/* Interactive β slightly lighter blue on dark */
--bg-interactive: var(--blue-500);
--bg-interactive-hover: var(--blue-400);
--bg-interactive-subtle: hsl(217, 91%, 15%);
--text-on-interactive: var(--gray-0);
/* Status β muted backgrounds for dark mode */
--status-success-bg: hsl(142, 40%, 13%);
--status-success-text: hsl(142, 70%, 58%);
--status-error-bg: hsl(0, 45%, 13%);
--status-error-text: hsl(0, 80%, 67%);
--status-warning-bg: hsl(38, 60%, 13%);
--status-warning-text: hsl(38, 90%, 65%);
}
}
/* ββ Component using semantic tokens (auto adapts to mode) ββββββββ */
.card {
background: var(--bg-base);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
border-radius: 8px;
padding: 1.5rem;
}
.card-title {
color: var(--text-primary);
font-weight: 700;
}
.card-description {
color: var(--text-secondary);
}
/* ββ Next.js / React: Toggle with localStorage ββββββββββββββββββββ
const toggle = () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
};
// On mount: apply saved theme before hydration
const saved = localStorage.getItem('theme');
if (saved === 'dark' || (!saved && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */Try It Online
Generate and export color palettes instantly with our free Color Picker Online β pick a base color, get complementary/triadic/tetradic schemes, preview contrast ratios, and copy CSS variables, HEX, HSL, or Tailwind config with one click.
Key Takeaways
- Use HSL for algorithmic generation: rotating hue by 120Β°/180Β° produces color harmonies; adjusting lightness generates tint/shade scales.
- Store CSS variables as raw HSL channels (
217 91% 60%nothsl(217, 91%, 60%)) to enable thehsl(var(--color-primary) / 0.5)alpha syntax. - OKLCH is the best CSS color space for
color-mix()β it is perceptually uniform and avoids the gray desaturation problem of RGB mixing. - WCAG AA requires 4.5:1 contrast for normal text, 3:1 for large text. Blue-500 (#3b82f6) on white is only 3.07:1 β use Blue-600+ for accessible body text.
- chroma.js LCH/Lab interpolation produces visually smoother gradients than linear RGB or HSL interpolation. Use
.mode('lch')on all scales. - Pillow + K-means is the standard approach for extracting dominant colors from images; the
colorthieflibrary wraps this for convenience. - Tailwind CSS variable references (
hsl(var(--color-primary) / <alpha-value>)) enable runtime theme switching without rebuilding the stylesheet. - Dark mode needs separate saturation tuning: reduce saturation by 10-20% and increase lightness of interactive elements so they read clearly without eye strain.
- Semantic token naming (
--bg-base,--text-primary) decouples components from color values, making global rebrands a single-file change. - ColorBrewer palettes (via
chroma.brewer) are designed for data visualization and guarantee perceptual distinguishability for categorical or sequential data.