DEV Community

Daniel Cheong
Daniel Cheong

Posted on

Why lightening a hex color in RGB gives you grey (and the HSL fix)

Your brand color has one hex value. A design system needs about nine: a light tint for hover backgrounds, a dark shade for pressed states, something in between for borders. The obvious way to generate them is to nudge the RGB channels up and down. It looks fine until you put the swatches side by side and notice the light end has gone grey and the dark end has gone muddy.

Here's why that happens, and how to build a scale that actually stays on-hue.

Why lightening in RGB goes grey

Take a saturated blue and lighten it the naive way — push every channel toward 255:

const lighten = (r, g, b, amount) => [
  Math.round(r + (255 - r) * amount),
  Math.round(g + (255 - g) * amount),
  Math.round(b + (255 - b) * amount),
];

lighten(37, 99, 235, 0.6); // #2563eb at 60% → [168, 192, 243]
Enter fullscreen mode Exit fullscreen mode

That result, #a8c0f3, isn't a lighter version of the same blue. As all three channels race toward 255 they converge, and the gap between them — which is what carries the hue — shrinks. The color desaturates on its way up. Do the same trick downward toward 0 and the channels collapse together again, so your dark shades drift toward a dead near-black instead of a rich navy.

RGB is a hardware model. It describes how much light each phosphor emits, not anything a human would call "the same color but lighter." Lightness and hue are tangled together in it, so you can't move one without disturbing the other.

Move the work into HSL

HSL splits a color into Hue, Saturation, and Lightness. If you convert your brand color once, you can slide L up and down while H and S stay put — which is exactly the "same color, different brightness" operation you actually wanted.

function hexToHsl(hex) {
  let [r, g, b] = hex.match(/\w\w/g).map((x) => parseInt(x, 16) / 255);
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  const l = (max + min) / 2;
  let h = 0, s = 0;
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
    else if (max === g) h = ((b - r) / d + 2) / 6;
    else h = ((r - g) / d + 4) / 6;
  }
  return [h * 360, s * 100, l * 100];
}
Enter fullscreen mode Exit fullscreen mode

Now a full scale is just a list of target lightness values with the original hue and saturation held constant:

function scale(hex) {
  const [h, s] = hexToHsl(hex);
  const stops = [95, 88, 78, 66, 54, 44, 36, 28, 18]; // 50 → 900
  return stops.map((l) => `hsl(${h.toFixed(0)} ${s.toFixed(0)}% ${l}%)`);
}

scale("#2563eb");
// hsl(217 83% 95%)  ← barely-tinted background
// hsl(217 83% 66%)  ← hover
// hsl(217 83% 54%)  ← your base, roughly
// hsl(217 83% 18%)  ← pressed / text-on-light
Enter fullscreen mode Exit fullscreen mode

Every stop reads as the same blue. The light end is a pale sky, not grey; the dark end is a deep navy, not sludge. And because modern CSS accepts hsl() directly, you can often skip converting back to hex entirely.

One catch: equal L steps don't look equal

HSL fixes the hue drift, but it has its own quirk — human eyes aren't linear about lightness. A jump from L 90% to L 80% looks like a small change; the same 10-point jump from 30% to 20% looks much larger, because we perceive dark differences more strongly. That's why the stops array above isn't evenly spaced: the steps are tighter at the top and wider at the bottom to look even.

If you want the perceptual spacing handled for you, that's the whole point of OKLCH, a newer color space built so that equal numeric steps look like equal visual steps:

:root {
  --blue-100: oklch(0.95 0.03 255);
  --blue-500: oklch(0.62 0.19 255); /* base */
  --blue-900: oklch(0.30 0.11 255);
}
Enter fullscreen mode Exit fullscreen mode

Here you can walk lightness in even increments and trust the result. OKLCH is supported in every current browser, so for a fresh design system it's often the better starting point than HSL.

The takeaways

  • Lightening or darkening in RGB desaturates — channels converge on 255 or 0 and the hue washes out.
  • Convert to HSL and vary only L to keep tints and shades on the same hue.
  • Perceived lightness isn't linear, so hand-tune your HSL stops or move to OKLCH, which spaces them for you.

I got tired of hand-tuning stop values, so I keep a small free color palette generator that spits out the full tint/shade scale from one hex and lets me copy it straight as CSS variables.

Top comments (0)