Appearance
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:
- Lightness — step linearly from 100 (white) through the seed color's lightness down to 0 (black).
- Hue — hold constant from the seed color.
- 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 ^ gammaWhere:
chromais the seed color's chromabaseis a normalized value between 0 and 1 (0 at the extremes, 1 at the seed)gammacontrols 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 numberPure 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 shades0.8— balanced curve (good default)1.0— linear fade, chroma stays fuller longer1.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 andpow()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.