DEV Community

monkeymore studio
monkeymore studio

Posted on

Color Palette Generation Algorithms: A Deep Dive into HSL-Based Color Theory

Introduction

Color theory is fundamental to design, art, and user interface development. Understanding how to generate harmonious color palettes programmatically empowers developers to create tools that help designers work more efficiently. In this article, we'll explore the mathematical algorithms behind common color palette generation techniques, all implemented using the HSL (Hue, Saturation, Lightness) color space.

Why HSL?

HSL is the preferred color space for color manipulation because:

  • Intuitive: Hue represents the pure color, Saturation represents color intensity, and Lightness represents brightness
  • Mathematically simple: Color relationships can be calculated using simple arithmetic on the Hue component
  • Perceptually relevant: Changes in HSL values produce predictable visual results

The HSL Color Wheel

Core Color Algorithms

1. Adjacent Colors (Analogous)

Concept: Colors that are next to each other on the color wheel. These create harmonious, cohesive palettes.

Mathematical Formula:

Given: base hue (H), number of colors (n), angle step (α)
For each color i from 0 to n-1:
  hue_i = H + α × (i - center)

Where center = floor(n / 2) to center the base color
Enter fullscreen mode Exit fullscreen mode

Implementation:

analogous(n?: number, angle?: number): Color[] {
  n = n || 3
  if (n < 3) n = 3
  if (n > 8) n = 8

  angle = angle || 15

  // Predefined angle offsets to center the base color
  let angles: number[] = [
    angle * -4,  // -60°
    angle * -3,  // -45°
    angle * -2,  // -30°
    angle * -1,  // -15°
    0,           // 0° (base color)
    angle * 1,   // +15°
    angle * 2,   // +30°
    angle * 3,   // +45°
    angle * 4,   // +60°
  ]

  // Start index mapping based on number of colors
  // For 3 colors: use indices [3, 4, 5] → [-15°, 0°, +15°]
  // For 4 colors: use indices [3, 4, 5, 6] → [-15°, 0°, +15°, +30°]
  let angleStartIndex = [-1, -1, -1, 3, 3, 2, 2, 1, 1, 0, 0]

  let analogous: Color[] = []
  let hsl = this.toHsl()
  let i = 0

  while (i < n) {
    let newH = hsl.h + angles[angleStartIndex[n] + i]

    // Normalize hue to 0-360 range
    if (newH > 360) {
      newH = newH % 360
    } else if (newH < 0) {
      newH = newH % 360
      newH = 360 + newH
    }

    let hslString = `hsl(${newH},${hsl.s},${hsl.l})`
    analogous.push(new Color(hslString))
    i++
  }

  return analogous
}
Enter fullscreen mode Exit fullscreen mode

Example:

  • Base color: Red (0°)
  • 5 colors, 15° step: [345°, 0°, 15°, 30°, 45°]
  • Result: Deep red → Red → Orange-red → Orange → Yellow-orange


2. Complementary Colors

Concept: Colors that are opposite each other on the color wheel (180° apart). They create maximum contrast and vibrant looks.

Mathematical Formula:

complementary_hue = (base_hue + 180) % 360
Enter fullscreen mode Exit fullscreen mode

Implementation:

complementary(): Color {
  let hsl = this.toHsl()
  let newH = hsl.h + 180
  if (newH > 360) {
    newH -= 360
  }

  let hslString = `hsl(${newH},${hsl.s},${hsl.l})`
  return new Color(hslString)
}
Enter fullscreen mode Exit fullscreen mode

Color Wheel Positions:

Red (0°) ↔ Cyan (180°)
Orange (30°) ↔ Azure (210°)
Yellow (60°) ↔ Blue (240°)
Green (120°) ↔ Magenta (300°)
Enter fullscreen mode Exit fullscreen mode


3. Split Complementary

Concept: Takes a base color and the two colors adjacent to its complement. Offers high contrast with less tension than pure complementary.

Mathematical Formula:

complement = base_hue + 180
split_colors = analogous(complement, n, angle)
// Replace complement with base color in results
Enter fullscreen mode Exit fullscreen mode

Implementation:

splitComplementary(n?: number, angle?: number) {
  let complementary = this.complementary()

  // Get analogous colors of the complement
  let splitComplementary: Color[] = complementary.analogous(n, angle)

  // Replace the complementary color with the base color
  return splitComplementary.map((e) => {
    if (e.toHsl().h == complementary.toHsl().h) {
      return this
    }
    return e
  })
}
Enter fullscreen mode Exit fullscreen mode

Example:

  • Base: Red (0°)
  • Complement: Cyan (180°)
  • Split (±15°): [165°, 180°, 195°]
  • Final palette: [165° (Teal), 0° (Red), 195° (Sky Blue)]

4. Triadic Colors

Concept: Three colors equally spaced around the color wheel (120° apart). Creates balanced, vibrant palettes.

Mathematical Formula:

For i = 0, 1, 2:
  hue_i = (base_hue + i × 120) % 360
Enter fullscreen mode Exit fullscreen mode

Implementation:

triadic(angle?: number) {
  angle = angle || 120
  return this.analogous(3, angle)
}
Enter fullscreen mode Exit fullscreen mode

Classic Triadic Combinations:

Primary triad: Red (0°), Yellow (120°), Blue (240°)
Secondary triad: Orange (30°), Green (150°), Purple (270°)
Enter fullscreen mode Exit fullscreen mode


5. Tetradic Colors (Rectangle/Square)

Concept: Four colors arranged in a rectangle on the color wheel. Two complementary pairs.

Mathematical Formula:

For rectangle with angle α:
  color1: base_hue
  color2: base_hue + α
  color3: base_hue + 180
  color4: base_hue + 180 + α
Enter fullscreen mode Exit fullscreen mode

Implementation:

tetradic(angle?: number) {
  let analogous = this.analogous(3, angle)
  return [
    analogous[0],                    // Base - angle
    this,                            // Base
    analogous[0].complementary(),    // (Base - angle) + 180
    this.complementary(),            // Base + 180
  ]
}
Enter fullscreen mode Exit fullscreen mode

Visual Representation:

6. Shades (Dark Variants)

Concept: Variations of a color by decreasing lightness. Creates depth and shadow effects.

Mathematical Formula:

For i = 1 to n:
  lightness_i = base_lightness - (i × step)
  minimum lightness = 10%
Enter fullscreen mode Exit fullscreen mode

Implementation:

shades(n?: number): Color[] {
  n = n || 5
  if (n < 1) n = 1

  let hsl = this.toHsl()
  let shades: Color[] = []
  let step = 5  // 5% lightness decrease per step
  let i = 1
  let light = hsl.l * 100 - step

  while (i++ <= n && light >= 10) {
    let hslString = `hsl(${hsl.h},${hsl.s},${light / 100})`
    shades.push(new Color(hslString))
    light -= step
  }

  return shades.reverse()  // Darkest first
}
Enter fullscreen mode Exit fullscreen mode

Lightness Progression:

Base: 50% lightness
Shade 1: 45% (-5%)
Shade 2: 40% (-10%)
Shade 3: 35% (-15%)
Shade 4: 30% (-20%)
Shade 5: 25% (-25%)
Enter fullscreen mode Exit fullscreen mode


7. Tints (Light Variants)

Concept: Variations of a color by increasing lightness. Creates highlights and softer versions.

Mathematical Formula:

For i = 1 to n:
  lightness_i = base_lightness + (i × step)
  maximum lightness = 90%
Enter fullscreen mode Exit fullscreen mode

Implementation:

tints(n?: number): Color[] {
  n = n || 5
  if (n < 1) n = 1

  let hsl = this.toHsl()
  let tints: Color[] = []
  let step = 5  // 5% lightness increase per step
  let i = 1
  let light = hsl.l * 100 + step

  while (i++ <= n && light <= 90) {
    let hslString = `hsl(${hsl.h},${hsl.s},${light / 100})`
    tints.push(new Color(hslString))
    light += step
  }

  return tints  // Lightest last
}
Enter fullscreen mode Exit fullscreen mode

8. Monochromatic Colors

Concept: Combines shades and tints of a single hue. Creates elegant, cohesive designs.

Mathematical Formula:

total_colors = n
shades_count = floor((n - 1) / 2)
tints_count = (n - 1) - shades_count

palette = [shades] + [base] + [tints]
Enter fullscreen mode Exit fullscreen mode

Implementation:

monochromatic(n: number) {
  n = n || 3
  if (n < 3) n = 3
  if (n > 8) n = 8

  let shdesSize = Math.floor((n - 1) / 2)
  let tintsSize = n - 1 - shdesSize

  return [...this.shades(shdesSize), this, ...this.tints(tintsSize)]
}
Enter fullscreen mode Exit fullscreen mode

Example for n=5:

[Shade 2, Shade 1, Base, Tint 1, Tint 2]
[40%, 45%, 50%, 55%, 60% lightness]
Enter fullscreen mode Exit fullscreen mode

9. Tones (Saturation Variants)

Concept: Variations of a color by adjusting saturation while keeping hue and lightness constant. Ranges from gray to vivid.

Mathematical Formula:

For each color i:
  saturation_i = base_saturation + offset_i

Where offsets range from -20% to +20% in 5% steps
Enter fullscreen mode Exit fullscreen mode

Implementation:

tones(n?: number) {
  n = n || 3
  if (n < 3) n = 3
  if (n > 8) n = 8

  let step = 5  // 5% saturation change
  let angles: number[] = [
    step * -4,  // -20%
    step * -3,  // -15%
    step * -2,  // -10%
    step * -1,  // -5%
    0,          // 0% (base)
    step * 1,   // +5%
    step * 2,   // +10%
    step * 3,   // +15%
    step * 4,   // +20%
  ]

  let angleStartIndex = [-1, -1, -1, 3, 3, 2, 2, 1, 1, 0, 0]
  let tones: Color[] = []

  let hsl = this.toHsl()
  let i = 0

  while (i < n) {
    let newS = hsl.s * 100 + angles[angleStartIndex[n] + i]

    // Clamp saturation to valid range
    newS = Math.max(0, Math.min(100, newS))

    let hslString = `hsl(${hsl.h},${newS / 100},${hsl.l})`
    tones.push(new Color(hslString))
    i++
  }

  return tones
}
Enter fullscreen mode Exit fullscreen mode

Saturation Progression:

Base: 80% saturation
Tone -2: 70% (muted)
Tone -1: 75% (slightly muted)
Base: 80%
Tone +1: 85% (more vivid)
Tone +2: 90% (most vivid)
Enter fullscreen mode Exit fullscreen mode


Visual Components

Color Wheel Visualization

const ColorWheel = ({ baseColor, colors, title }: ColorWheelProps) => {
  const size = 200
  const center = size / 2
  const radius = size / 2 - 20

  // Convert hue to Cartesian coordinates
  const getPosition = (hue: number, r: number = radius) => {
    // Subtract 90° to start from top (12 o'clock position)
    const angle = (hue - 90) * (Math.PI / 180)
    return {
      x: center + r * Math.cos(angle),
      y: center + r * Math.sin(angle),
    }
  }

  // Render color wheel segments
  const generateWheelGradient = () => {
    const segments = []
    const segmentCount = 36
    for (let i = 0; i < segmentCount; i++) {
      const hue = (i / segmentCount) * 360
      segments.push(
        <path
          key={i}
          d={`M ${center} ${center} L ${getPosition(hue, radius).x} ${getPosition(hue, radius).y} 
              A ${radius} ${radius} 0 0 1 ${getPosition(hue + 360 / segmentCount, radius).x} ${getPosition(hue + 360 / segmentCount, radius).y} Z`}
          fill={`hsl(${hue}, 100%, 50%)`}
          opacity={0.3}
        />
      )
    }
    return segments
  }
}
Enter fullscreen mode Exit fullscreen mode

Monochromatic Visualizer

Shows lightness variations on a vertical gradient bar:

const MonochromaticVisualizer = ({ baseColor, colors }) => {
  const baseHsl = baseColor.toHsl()

  return (
    <div className="relative h-48 w-12 overflow-hidden rounded-lg border">
      {/* Gradient background */}
      <div
        style={{
          background: `linear-gradient(to bottom, 
            hsl(${baseHsl.h}, ${baseHsl.s * 100}%, 90%), 
            hsl(${baseHsl.h}, ${baseHsl.s * 100}%, 50%), 
            hsl(${baseHsl.h}, ${baseHsl.s * 100}%, 10%))`,
        }}
      />

      {/* Position markers */}
      {colors.map((color, index) => {
        const hsl = color.toHsl()
        const lightness = hsl.l * 100
        const position = 90 - lightness * 0.8 // Invert: lighter = top

        return (
          <div
            key={index}
            className="absolute left-1/2 -translate-x-1/2 rounded-full"
            style={{
              backgroundColor: color.toHexString(),
              top: `${position}%`,
            }}
          />
        )
      })}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Complete Algorithm Flow


Practical Applications

UI Design System

// Generate a complete design system
const baseColor = new Color('#3B82F6')  // Blue

const designSystem = {
  primary: baseColor.toHexString(),
  shades: baseColor.shades(4),          // Darker variants
  tints: baseColor.tints(4),            // Lighter variants
  analogous: baseColor.analogous(3),    // Related colors
  complementary: baseColor.complementary(), // Accent color
}
Enter fullscreen mode Exit fullscreen mode

Color Grouping

// Group colors by their closest RYB wheel color
const groupColors = (palette: Color[], wheel: Color[]) => {
  const grouper = new ColorGrouper(wheel)
  grouper.process(palette)
  return grouper.groupedColors
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimizations

Caching Strategy

class Color {
  private _cache: Map<string, any> = new Map()
  private _cacheEnabled: boolean = true

  private getCached<T>(key: string, compute: () => T): T {
    if (!this._cacheEnabled) return compute()
    if (this._cache.has(key)) return this._cache.get(key)

    const value = compute()
    this._cache.set(key, value)
    return value
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Color palette generation is a perfect blend of mathematical precision and artistic intuition. By understanding the HSL color space and the relationships between hues, we can programmatically create harmonious color schemes that follow established color theory principles.

Key takeaways:

  • Hue relationships define color harmony (analogous, complementary, triadic)
  • Lightness variations create depth (shades and tints)
  • Saturation adjustments control vividness (tones)
  • Combining these produces comprehensive design systems

Try It Yourself

Explore color palettes and experiment with these algorithms:

👉 Color Palette Tool

Generate harmonious color schemes for your projects using these mathematical color relationships!


Built with ❤️ using TypeScript, HSL color theory, and React.

Top comments (0)