Colour Palette Generator
Generate a complete, accessible colour system from a single brand hex. Produces Tailwind v4 CSS ready to paste into your project.
Workflow
Step 1: Get the Brand Hex
Ask for the primary brand colour. A single hex like #0D9488 is enough.
Step 2: Generate 11-Shade Scale
Convert hex to HSL, then generate shades by varying lightness while keeping hue constant.
Hex to HSL Conversion
function hexToHSL(hex) {
hex = hex.replace(/^#/, '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const diff = max - min;
let l = (max + min) / 2;
let s = 0;
if (diff !== 0) {
s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);
}
let h = 0;
if (diff !== 0) {
if (max === r) h = ((g - b) / diff + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / diff + 2) / 6;
else h = ((r - g) / diff + 4) / 6;
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
Lightness and Saturation Values
| Shade | Lightness | Saturation Mult | Use Case |
|---|---|---|---|
| 50 | 97% | 0.80 | Subtle backgrounds |
| 100 | 94% | 0.80 | Hover states |
| 200 | 87% | 0.85 | Borders, dividers |
| 300 | 75% | 0.90 | Disabled states |
| 400 | 62% | 0.95 | Placeholder text |
| 500 | 48% | 1.00 | Brand colour baseline |
| 600 | 40% | 1.00 | Primary actions (often the brand colour) |
| 700 | 33% | 1.00 | Hover on primary |
| 800 | 27% | 1.00 | Active states |
| 900 | 20% | 1.00 | Text on light bg |
| 950 | 10% | 1.00 | Darkest accents |
Reduce saturation for lighter shades (50-200 by 15-20%, 300-400 by 5-10%) to prevent overly vibrant pastels. Keep full saturation for 500-950.
Complete Scale Generator
function generateShadeScale(brandHex) {
const { h, s } = hexToHSL(brandHex);
const shades = {
50: { l: 97, sMul: 0.8 }, 100: { l: 94, sMul: 0.8 },
200: { l: 87, sMul: 0.85 }, 300: { l: 75, sMul: 0.9 },
400: { l: 62, sMul: 0.95 }, 500: { l: 48, sMul: 1.0 },
600: { l: 40, sMul: 1.0 }, 700: { l: 33, sMul: 1.0 },
800: { l: 27, sMul: 1.0 }, 900: { l: 20, sMul: 1.0 },
950: { l: 10, sMul: 1.0 }
};
const result = {};
for (const [shade, { l, sMul }] of Object.entries(shades)) {
result[shade] = `hsl(${h}, ${Math.round(s * sMul)}%, ${l}%)`;
}
return result;
}
HSL to Hex Conversion
function hslToHex(h, s, l) {
s = s / 100; l = 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; }
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`.toUpperCase();
}
Verification
Generated shades should look like the same colour family with smooth progression. Light shades (50-300) usable for backgrounds, dark shades (700-950) usable for text. Brand colour recognisable in 500-700.
Step 3: Map Semantic Tokens
Every background token MUST have a paired foreground token. Never use a background without its pair or dark mode will break.
Light Mode Tokens
| Token | Shade | Use Case |
|---|---|---|
background | white | Page backgrounds |
foreground | 950 | Body text |
card | white | Card backgrounds |
card-foreground | 900 | Card text |
popover | white | Dropdown/tooltip backgrounds |
popover-foreground | 950 | Dropdown text |
primary | 600 | Primary buttons, links |
primary-foreground | white | Text on primary buttons |
secondary | 100 | Secondary buttons |
secondary-foreground | 900 | Text on secondary buttons |
muted | 50 | Disabled backgrounds, subtle sections |
muted-foreground | 600 | Muted text, captions |
accent | 100 | Hover states, subtle highlights |
accent-foreground | 900 | Text on accent backgrounds |
destructive | red-600 | Delete buttons, errors |
destructive-foreground | white | Text on destructive buttons |
border | 200 | Input borders, dividers |
input | 200 | Input field borders |
ring | 600 | Focus rings |
Dark Mode Tokens
| Token | Shade | Use Case |
|---|---|---|
background | 950 | Page backgrounds |
foreground | 50 | Body text |
card | 900 | Card backgrounds |
card-foreground | 50 | Card text |
popover | 900 | Dropdown backgrounds |
popover-foreground | 50 | Dropdown text |
primary | 500 | Primary buttons (brighter in dark) |
primary-foreground | white | Text on primary buttons |
secondary | 800 | Secondary buttons |
secondary-foreground | 50 | Text on secondary buttons |
muted | 800 | Disabled backgrounds |
muted-foreground | 400 | Muted text |
accent | 800 | Hover states |
accent-foreground | 50 | Text on accent backgrounds |
destructive | red-500 | Delete buttons (brighter) |
destructive-foreground | white | Text on destructive |
border | 800 | Borders |
input | 800 | Input borders |
ring | 500 | Focus rings |
Dark Mode Inversion Pattern
Dark mode inverts lightness while preserving hue and saturation. Swap extremes (50 becomes 950, 950 becomes 50), preserve middle (500 stays near 500).
| Light Shade | Dark Equivalent | Role |
|---|---|---|
| 50 | 950 | Backgrounds |
| 100 | 900 | Subtle backgrounds |
| 200 | 800 | Borders |
| 500 | 500 (slightly brighter) | Brand baseline |
| 600 | 400 | Primary actions |
| 950 | 50 | Text colour |
Key dark mode principles:
- Use shade 500 (not 600) for primary -- brighter for visibility on dark backgrounds
- Use shade 50 (off-white) for text instead of pure
#FFFFFF-- easier on eyes - Borders need ~10-15% lighter than background (e.g. 800 border on 950 background)
- Higher elevation = lighter colour (opposite of light mode shadows)
- Always update foreground when changing background
Step 4: Check Contrast
WCAG Minimum Ratios
| Content Type | AA | AAA |
|---|---|---|
| Normal text (<18px or <14px bold) | 4.5:1 | 7:1 |
| Large text (>=18px or >=14px bold) | 3:1 | 4.5:1 |
| UI components (buttons, borders) | 3:1 | Not defined |
| Graphical objects (icons, charts) | 3:1 | Not defined |
Target AA for most projects, AAA for high-accessibility needs (government, healthcare).
Luminance and Contrast Formulas
function getLuminance(hex) {
hex = hex.replace(/^#/, '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const rsRGB = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
const gsRGB = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
const bsRGB = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
return 0.2126 * rsRGB + 0.7152 * gsRGB + 0.0722 * bsRGB;
}
function getContrastRatio(hex1, hex2) {
const lum1 = getLuminance(hex1);
const lum2 = getLuminance(hex2);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
Quick Check Table -- Light Mode
| Foreground | Background | Ratio | Pass? | Use Case |
|---|---|---|---|---|
| 950 | white | 18.5:1 | AAA | Body text |
| 900 | white | 14.2:1 | AAA | Card text |
| 700 | white | 8.1:1 | AAA | Text |
| 600 | white | 5.7:1 | AA | Text, buttons |
| 500 | wh |