Color Shades Generator: From Linear Interpolation to Tailwind Export
Color Shades Generator: From Linear Interpolation to Tailwind Export#
Design systems need a complete color palette, not just one primary color. Designers often say “give me variations of this blue,” but manually adjusting colors is inefficient. Let’s talk about generating color shades, tints, and tones programmatically.
Three Types of Color Variations#
First, let’s clarify the definitions:
- Shades: Interpolate toward black - colors get darker
- Tints: Interpolate toward white - colors get lighter
- Tones: Interpolate toward gray - colors get more muted
The difference lies in the target color, but the algorithm is the same.
Core Algorithm: RGB Linear Interpolation#
The most straightforward approach is linear interpolation in RGB space:
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '')
if (h.length === 3) {
// Handle shorthand like #ABC
return [
parseInt(h[0] + h[0], 16),
parseInt(h[1] + h[1], 16),
parseInt(h[2] + h[2], 16)
]
}
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16)
]
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (n: number) =>
Math.max(0, Math.min(255, Math.round(n)))
.toString(16)
.padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function interpolate(
c1: [number, number, number],
c2: [number, number, number],
ratio: number
): [number, number, number] {
return [
Math.round(c1[0] + (c2[0] - c1[0]) * ratio),
Math.round(c1[1] + (c2[1] - c1[1]) * ratio),
Math.round(c1[2] + (c2[2] - c1[2]) * ratio),
]
}
The interpolate function is the core: given two colors and a ratio, calculate the intermediate color.
Generating swatches:
function generateSwatches(hex: string) {
const base = hexToRgb(hex)
const shades = []
const tints = []
const tones = []
for (let i = 0; i <= 10; i++) {
const ratio = i / 10
// Shade: interpolate toward black
shades.push(rgbToHex(...interpolate(base, [0, 0, 0], ratio)))
// Tint: interpolate toward white
tints.push(rgbToHex(...interpolate(base, [255, 255, 255], ratio)))
// Tone: interpolate toward neutral gray
tones.push(rgbToHex(...interpolate(base, [128, 128, 128], ratio)))
}
return { shades, tints, tones }
}
From #3B82F6 (Tailwind blue-500), this generates 11 levels from 0% (original) to 100% (target).
The Problem with RGB Interpolation#
RGB linear interpolation has a flaw: intermediate colors can look “muddy”.
For example, interpolating from pure red #FF0000 to pure green #00FF00 passes through #808000 (dark yellow) instead of bright yellow. This happens because RGB space is perceptually non-uniform.
A better approach is interpolating in HSL space:
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h = 0, s = 0, l = (max + min) / 2
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)) / 6; break
case g: h = ((b - r) / d + 2) / 6; break
case b: h = ((r - g) / d + 4) / 6; break
}
}
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]
}
However, in practice, RGB interpolation works fine for generating Shades/Tints/Tones because the target colors (black/white/gray) have meaningless H and S values anyway. HSL only shows clear advantages when interpolating between two saturated colors.
Exporting Design System Configs#
After generating swatches, make them usable for designers and developers. Three common formats:
CSS Variables#
function exportCssVars(swatches) {
const lines = [`:root {`]
swatches.shades.forEach(s => {
lines.push(` --color-shade-${s.ratio}: ${s.hex};`)
})
swatches.tints.forEach(s => {
lines.push(` --color-tint-${s.ratio}: ${s.hex};`)
})
lines.push(`}`)
return lines.join('\n')
}
Output:
:root {
--color-shade-0: #3b82f6;
--color-shade-10: #3675dd;
--color-shade-20: #3168c4;
/* ... */
--color-tint-0: #3b82f6;
--color-tint-10: #4e8ff7;
--color-tint-20: #619cf8;
/* ... */
}
Tailwind Config#
function exportTailwind(baseColor: string, swatches) {
const lines = ['module.exports = { theme: { extend: { colors: {']
const name = baseColor.replace('#', '')
lines.push(` '${name}': '${baseColor}',`)
swatches.shades.forEach(s => {
lines.push(` '${name}-shade-${s.ratio}': '${s.hex}',`)
})
swatches.tints.forEach(s => {
lines.push(` '${name}-tint-${s.ratio}': '${s.hex}',`)
})
lines.push('} } } }')
return lines.join('\n')
}
JSON Format#
Easy for backend or other tools to consume:
{
"base": "#3B82F6",
"shade-0": "#3b82f6",
"shade-10": "#3675dd",
"tint-0": "#3b82f6",
"tint-10": "#4e8ff7"
}
Edge Cases#
1. 3-Digit Shorthand HEX#
#ABC needs to expand to #AABBCC:
if (h.length === 3) {
return [
parseInt(h[0] + h[0], 16),
parseInt(h[1] + h[1], 16),
parseInt(h[2] + h[2], 16)
]
}
2. Value Overflow#
rgbToHex must enforce boundaries:
const toHex = (n: number) =>
Math.max(0, Math.min(255, Math.round(n)))
While linear interpolation theoretically won’t overflow, this guard provides safety.
3. Random Color Generation#
Add a random button for interactivity:
function randomHex(): string {
return '#' + Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0')
}
16777215 is 0xFFFFFF, the maximum value in 24-bit color space.
Real-World Use Cases#
- Design System Building: Quickly generate complete palettes from brand colors
- Dark Mode Adaptation: Map the same hue’s variations across themes
- Accessibility Checks: Filter combinations meeting WCAG contrast standards
- Code Generation: Export Tailwind configs developers can use directly
Based on these ideas, I built: Color Shades Generator
Features:
- Real-time Shade/Tint/Tone generation
- Click to copy any color
- Auto-display HEX/RGB/HSL formats
- One-click export to CSS variables/JSON/Tailwind config
Simple code, but solves real problems in design system workflows. Hope this helps.
Related: Color Palette Generator | Color Contrast Checker