Skip to content

CSS Color Scales

When to use

Use this technique when you need a complete color palette — a sequence of tints and shades from a single seed color — that looks perceptually uniform. This is common in design systems where each color (blue, red, green, etc.) needs a full scale from near-white to near-black (e.g. blue-050 through blue-950).

Naive approaches that mix with white or black, or that change only the lightness while holding chroma constant, produce washed-out or oversaturated results. This pattern uses the LCH color space with gamma-corrected chroma easing to keep colors vibrant in the middle of the scale and subdued at the extremes.

The pattern

Work in the LCH color space (lch() in CSS) and control three aspects independently:

  1. Lightness — step linearly from 100 (white) through the seed color's lightness down to 0 (black).
  2. Hue — hold constant from the seed color.
  3. Chroma — ease using a gamma correction curve so it peaks at the seed color and tapers toward zero at both the light and dark extremes.

Once the scale is defined, reference its steps as custom properties anywhere in your component styles:

css
.button-primary {
  background-color: var(--blue-500);
  color: var(--blue-000);
}

.button-primary:hover {
  background-color: var(--blue-600);
}

.alert-error {
  background-color: var(--red-100);
  border: 1px solid var(--red-300);
  color: var(--red-800);
}

Chroma easing with gamma correction

The core formula:

corrected_chroma = chroma × base ^ gamma

Where:

  • chroma is the seed color's chroma
  • base is a normalized value between 0 and 1 (0 at the extremes, 1 at the seed)
  • gamma controls the curve shape (lower values ease more gently, higher values keep chroma fuller longer)

Visualized, chroma across the scale forms a bell curve:

max -|             o
     |        o         o
     |     o               o
     |   o                   o
     |  o                     o
     | o                       o
     |o                         o
  0 -o---------------------------o
     |             |             |
    000           500          1000
  
              color number

Pure CSS implementation

Use CSS lch() with relative color syntax and calc() with pow() for the gamma correction. Define a --chroma-power custom property to control the curve:

css
:root {
  --chroma-power: 0.8;
  
  /* Seed colors */
  --color-blue: lch(53.4 70.2 252);
  --color-red: lch(46 95.78 37.97);
}

Each scale step computes a color from the seed by setting a specific lightness and easing the chroma:

css
:root {
  /* Blue scale */
  --blue-000: lch(
    from var(--color-blue) 100
    calc(c * pow(0.0, var(--chroma-power))) h
  );
  --blue-100: lch(
    from var(--color-blue) 90
    calc(c * pow(0.2, var(--chroma-power))) h
  );
  --blue-200: lch(
    from var(--color-blue) 80
    calc(c * pow(0.4, var(--chroma-power))) h
  );
  --blue-300: lch(
    from var(--color-blue) 70
    calc(c * pow(0.6, var(--chroma-power))) h
  );
  --blue-400: lch(
    from var(--color-blue) 60
    calc(c * pow(0.8, var(--chroma-power))) h
  );
  --blue-500: lch(
    from var(--color-blue) 50
    calc(c * pow(1.0, var(--chroma-power))) h
  );
  --blue-600: lch(
    from var(--color-blue) 40
    calc(c * pow(0.8, var(--chroma-power))) h
  );
  --blue-700: lch(
    from var(--color-blue) 30
    calc(c * pow(0.6, var(--chroma-power))) h
  );
  --blue-800: lch(
    from var(--color-blue) 20
    calc(c * pow(0.4, var(--chroma-power))) h
  );
  --blue-900: lch(
    from var(--color-blue) 10
    calc(c * pow(0.2, var(--chroma-power))) h
  );
  --blue-1000: lch(
    from var(--color-blue) 0
    calc(c * pow(0.0, var(--chroma-power))) h
  );
}

The c and h inside lch(from ...) refer to the chroma and hue of the origin color. The pow() function applies gamma correction to the base value.

Tuning the gamma curve

The --chroma-power value controls how aggressively chroma fades at the extremes:

  • 0.5 — chroma drops quickly, producing more muted tints and shades
  • 0.8 — balanced curve (good default)
  • 1.0 — linear fade, chroma stays fuller longer
  • 1.5 — chroma persists deep into the tints and shades, producing more vivid extremes

Adjust per color if needed — warm colors (red, orange) may benefit from a slightly lower power to avoid oversaturation, while cool colors (blue, teal) can handle higher values.

SCSS helper for reduced verbosity

When generating many color scales, an SCSS mixin eliminates the repetition:

scss
:root {
  --chroma-power: 0.8;
  
  --color-blue: lch(53.4 70.2 252);
  --color-red: lch(46 95.78 37.97);
  --color-green: lch(48.3 109 141);
  
  @include color-scale("blue", var(--color-blue));
  @include color-scale("red", var(--color-red));
  @include color-scale("green", var(--color-green));
}

Implementing the SCSS mixin

The ease-chroma function applies gamma correction to a base value, and the color-scale mixin generates all 12 steps for a given name and seed color:

scss
@function ease-chroma($base) {
  @return calc(c * pow($base, var(--chroma-power)));
}

@mixin color-scale($name, $base-color) {
  --#{$name}-000: #{lch(from $base-color 100 ease-chroma(0.0) h)};
  --#{$name}-050: #{lch(from $base-color 95  ease-chroma(0.1) h)};
  --#{$name}-100: #{lch(from $base-color 90  ease-chroma(0.2) h)};
  --#{$name}-200: #{lch(from $base-color 80  ease-chroma(0.4) h)};
  --#{$name}-300: #{lch(from $base-color 70  ease-chroma(0.6) h)};
  --#{$name}-400: #{lch(from $base-color 60  ease-chroma(0.8) h)};
  --#{$name}-500: #{lch(from $base-color 50  ease-chroma(1.0) h)};
  --#{$name}-600: #{lch(from $base-color 40  ease-chroma(0.8) h)};
  --#{$name}-700: #{lch(from $base-color 30  ease-chroma(0.6) h)};
  --#{$name}-800: #{lch(from $base-color 20  ease-chroma(0.4) h)};
  --#{$name}-900: #{lch(from $base-color 10  ease-chroma(0.2) h)};
  --#{$name}-1000: #{lch(from $base-color 0  ease-chroma(0.0) h)};
}

Trade-offs

  • LCH vs HSL: LCH is perceptually uniform — equal numeric steps produce equal visual steps. HSL's lightness channel is not perceptually uniform, so HSL-based scales have inconsistent visual spacing.
  • Browser support: lch() with relative color syntax and pow() requires modern browsers. As of 2025, support is broad but not universal. For older browsers, generate the resolved color values at build time.
  • SCSS vs pure CSS: The SCSS mixin reduces repetition but adds a build step. Pure CSS works without tooling but is more verbose. Choose based on your project's existing toolchain.
  • 21-step vs 11-step scales: The full 21-step scale (000–1000 in increments of 50) gives maximum flexibility. If you only need broad strokes, an 11-step scale (000–1000 in increments of 100) is simpler to maintain.