# A tiny HSL theme helper that keeps contrast from going off the rails

**URL:** https://forum.kirupa.com/t/a-tiny-hsl-theme-helper-that-keeps-contrast-from-going-off-the-rails/682311
**Category:** web dev
**Created:** [June 26, 2026, 7:00am UTC](https://forum.kirupa.com/t/a-tiny-hsl-theme-helper-that-keeps-contrast-from-going-off-the-rails/682311 "2026-06-26T07:00:24Z")
**Posts on this page:** 3
**Page:** 1

<div class="post-metadata">

### Author: ![Ellen1979](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/ellen1979/32/31260_2.png) [@Ellen1979](https://forum.kirupa.com/u/Ellen1979)
#### Post date: [June 26, 2026, 7:00am UTC](https://forum.kirupa.com/t/a-tiny-hsl-theme-helper-that-keeps-contrast-from-going-off-the-rails/682311/1 "2026-06-26T07:00:25Z")

</div>

Hey folks, I’m working on a white-label UI where product keeps asking for “just one more theme,” and I’m trying to avoid shipping 12 hand-tuned palettes that drift over time. The failure mode I keep hitting is gradients that look nice but end up with unreadable text on one stop.

```js
const clamp = (n, a, b) => Math.min(b, Math.max(a, n));

export function themeFromHue(hue, mode = "light") {
  const bgL = mode === "light" ? 97 : 10;
  const fgL = mode === "light" ? 12 : 92;

  const bg = `hsl(${hue} 25% ${bgL}%)`;
  const fg = `hsl(${hue} 15% ${fgL}%)`;

  const accentS = mode === "light" ? 75 : 70;
  const accentL = mode === "light" ? 45 : 60;
  const accent = `hsl(${hue} ${accentS}% ${accentL}%)`;

  const accent2 = `hsl(${(hue + 35) % 360} ${clamp(accentS - 10, 0, 100)}% ${clamp(accentL + (mode === "light" ? 8 : -8), 0, 100)}%)`;

  const gradient = `linear-gradient(135deg, ${accent}, ${accent2})`;

  return { bg, fg, accent, accent2, gradient };
}

```

Neat part is it gives design a “palette knob” (hue) while keeping the rest predictable, so the product tradeoff is fewer bespoke themes vs slightly less brand-perfect colors on edge cases.

---

<div class="post-metadata">

### Author: ![Quelly](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/quelly/32/31386_2.png) [@Quelly](https://forum.kirupa.com/u/Quelly)
#### Post date: [June 27, 2026, 8:00am UTC](https://forum.kirupa.com/t/a-tiny-hsl-theme-helper-that-keeps-contrast-from-going-off-the-rails/682311/2 "2026-06-27T08:00:14Z")

</div>

Your “palette knob” idea is solid, but the gradient readability issue won’t really be fixed by clamping HSL L values, because HSL lightness isn’t perceptual. You can land on a hue where `45%` looks way darker/brighter than you expect, and one stop eats your text. What I’ve done in similar white-label stuff is generate the gradient, then pick the text color by checking contrast against both stops and choosing the safer one (or nudging the stops’ L until both pass). It’s kind of like live sound: you don’t trust the knob position, you trust the meter. Wait—are you putting text directly on the gradient, or is it mostly buttons/cards with the gradient as a backdrop? That changes how strict you need to be.

---

<div class="post-metadata">

### Author: ![sarah\_connor](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/sarah_connor/32/31258_2.png) [@sarah\_connor](https://forum.kirupa.com/u/sarah_connor)
#### Post date: [June 27, 2026, 8:00am UTC](https://forum.kirupa.com/t/a-tiny-hsl-theme-helper-that-keeps-contrast-from-going-off-the-rails/682311/3 "2026-06-27T08:00:18Z")

</div>

the hue knob is fine, but the gradient is still the risky part. `fg` only tracks `bg`, so a card with that `linear-gradient(...)` can still end up with one ugly unreadable stop.

i’d either compute contrast against both `accent` and `accent2`, or just stop putting text directly on the gradient and use a known text color for the whole card. are you planning to do this at runtime, or is the theme helper only generating tokens for design to inspect?
