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).