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

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.

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.

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.

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?