Color Scale Algorithm

How Tokens generates professional, perceptually balanced color scales

Overview

Tokens uses a sophisticated dual-algorithm approach to generate that are both aesthetically pleasing and functionally superior for UI design. The system automatically detects whether a color is chromatic (has hue) or achromatic (neutral/gray) and applies the appropriate algorithm.

Why Two Algorithms?

Different colors have different needs in UI design:

  • Chromatic colors (blues, greens, oranges, etc.) benefit from vibrant mid-tones and smooth transitions
  • Achromatic colors (grays, neutrals) need subtle light shades for backgrounds and high-contrast dark shades for text

The OKLCH Foundation

Both algorithms use color space, which provides perceptually uniform color manipulation. This means equal numeric changes produce equal visual changes—something RGB and HSL can't guarantee.

What is OKLCH?

L - Lightness (0-1)

Perceived brightness from black (0) to white (1). Unlike HSL, lightness values match human perception.

C - Chroma (0+)

Colorfulness or saturation. Higher values = more vibrant. Can exceed 0.37 for very saturated colors.

H - Hue (0-360°)

Color angle: 0° = red, 120° = green, 240° = blue, etc.

Learn more at oklch.com

Chromatic Color Algorithm

For colors with hue (blues, greens, oranges, purples, etc.), we use a three-part approach inspired by Matt Ström's WCAG-driven color palette generation.

1. Smooth Lightness Distribution

Lightness steps are calibrated to provide visually even progression from light to dark, optimized for UI elements like buttons, cards, and badges.

Shade  Offset from base (500)
  50   +0.33 (much lighter)
 100   +0.28
 200   +0.22
 300   +0.15
 400   +0.07
 500    0.00 (exact base color)
 600   -0.10
 700   -0.20
 800   -0.28
 900   -0.34
 950   -0.38
2. Parabolic Chroma Curve

Chroma follows a parabolic curve that peaks at shade 500, creating vibrant mid-tones while reducing saturation at extremes for natural-looking lighter and darker shades.

Formula: C(n) = -4(max-min)n² + 4(max-min)n + min
Where n = normalized position (0-1)
      max = 1.1 (10% boost at peak)
      min = 0.3 (70% reduction at extremes)

This ensures colors remain vibrant where they matter most (buttons, links, accents) while preventing oversaturation in backgrounds and dark UI elements.

3. Bezold-Brücke Hue Shift Compensation

Lighter colors appear to shift hue perceptually—a phenomenon called the Bezold-Brücke effect. We compensate by slightly rotating hue in lighter shades.

Formula: H(n) = H_base + 5(1 - n)
Where n = normalized position (0-1)

Example: If base hue = 210° (blue)
  Shade 50:  215° (slightly warmer)
  Shade 500: 210° (exact base)
  Shade 950: 210° (stays true)

This subtle adjustment (±5°) keeps colors looking consistent across the entire scale, preventing the "muddy" or "off" appearance common in naive algorithms.

Reference

This approach is inspired by Matt Ström's article on generating WCAG-compliant color palettes for Stripe:

Generating Color Palettes →

Achromatic Color Algorithm

For neutral colors (grays with chroma < 0.01), we use a distribution pattern inspired by that prioritizes readability and subtle UI backgrounds.

Tailwind-Inspired Distribution

Neutrals need different behavior than chromatic colors. Light shades must be very subtle (close to white) for card backgrounds and subtle borders, while dark shades need aggressive contrast for readable text.

Shade  Offset from base (500)  Step Size  Purpose
  50   +0.429                 0.015      Subtle backgrounds
 100   +0.414                 0.048      Very light UI elements
 200   +0.366                 0.052      Light borders/dividers
 300   +0.314                 0.162      Card backgrounds
 400   +0.152                 ⚡HUGE     Hover states
 500    0.000 (base)          0.117      Base neutral
 600   -0.117                 0.068      Muted text
 700   -0.185                 0.102      Secondary text
 800   -0.287                 0.064      Body text (dark mode)
 900   -0.351                 0.060      Headings
 950   -0.411                           Deep backgrounds

Notice the massive lightness drops between shades 300-500. This creates excellent contrast for text on light backgrounds while keeping the lighter shades subtle and non-distracting.

Real-World Validation

This distribution closely matches Tailwind CSS's neutral scale, which has been battle-tested across thousands of production applications:

Tailwind neutral-500 in OKLCH: oklch(0.556 0 0)
Our algorithm at base 0.556:
  50:  0.985 (vs Tailwind 0.985) ✓
  400: 0.708 (vs Tailwind 0.708) ✓
  950: 0.145 (vs Tailwind 0.145) ✓

Reference

This approach is based on analysis of Tailwind CSS's color system:

Building a Tailwind-Ready Color System →

Benefits of This Approach

Perceptually Uniform

OKLCH ensures equal lightness changes produce equal visual changes. Shade 300 looks equally lighter than 400 as 700 looks darker than 600.

Vibrant Mid-Tones

The parabolic chroma curve creates punchy, engaging colors for interactive elements like buttons and links without oversaturating backgrounds.

Excellent Readability

Neutral scales provide high-contrast text options while keeping light shades subtle enough for backgrounds and dividers.

Battle-Tested

Based on proven approaches from Tailwind CSS and Matt Ström's work at Stripe, used in thousands of production applications.

Natural-Looking

Hue shift compensation prevents the "muddy" or "off" appearance common in algorithmically generated scales.

Harmonious

Both chromatic and achromatic scales work together seamlessly, ensuring your entire design system feels cohesive.

Technical Implementation

Detection Threshold

A color is considered achromatic (neutral) if its chroma value is less than 0.01. This catches pure grays as well as colors that are nearly imperceptible from gray.

Constraint Preservation

Regardless of algorithm, shade 500 always exactly matches your input color. This ensures your brand color appears precisely as intended in the generated scale.

View the Source

The complete implementation is open source and available in our repository:

View oklch.ts on GitHub →