A small HSL theme palette helper that outputs CSS variables

Yo folks, I’m cleaning up our component styling and trying to stop hardcoding random hex colors all over the place. I wanted one source of truth that spits out a palette + gradients, but the failure mode is obvious: you can generate colors that look fine yet miss contrast once you swap themes.

// Generate a theme palette from a single hue using HSL
export function makeTheme({
  hue = 210,
  sat = 70,
  mode = "light", // "light" | "dark"
} = {}) {
  const isDark = mode === "dark";
  const ramp = [
    ["--bg", isDark ? 8 : 98],
    ["--surface", isDark ? 14 : 96],
    ["--text", isDark ? 92 : 12],
    ["--muted", isDark ? 70 : 42],
    ["--primary", isDark ? 62 : 46],
    ["--primary-2", isDark ? 54 : 56],
  ];

  const vars = Object.fromEntries(
    ramp.map(([name, l]) => [name, `hsl(${hue} ${sat}% ${l}%)`])
  );

  vars["--gradient"] = `linear-gradient(135deg, ${vars["--primary"]}, ${vars["--primary-2"]})`;

  // quick contrast hint (not WCAG-perfect): estimate luminance from HSL lightness
  const approxLum = (hsl) => Number(hsl.match(/(\d+(?:\.\d+)?)%\)$/)?.[1] ?? 50) / 100;
  vars["--contrast-hint"] =
    Math.abs(approxLum(vars["--text"]) - approxLum(vars["--bg"])) > 0.75
      ? "ok"
      : "risky";

  return vars;
}

Neat part is it keeps components dumb: they just consume var(--primary) etc, and swapping themes is just swapping a hue/mode, plus you get a gradient for free.

Your --contrast-hint is going to lie to you the moment you change saturation, because HSL “lightness” isn’t perceived luminance. That part would make me nervous if you’re using it to gate theme swaps. Small fix: compute contrast from actual sRGB relative luminance (convert HSL → RGB → luminance), even if it’s still a “hint” and not full WCAG checks. Has anyone here already dropped a tiny HSL→RGB function they like for this? I always end up rewriting it and hating it.

your --contrast-hint is gonna be kinda flaky because HSL lightness isn’t actual perceived brightness. like, a yellow at 50% L can look way louder than a blue at 50% L, so the same “ok” can turn into “why is this unreadable” just by changing hue.

i’d keep the palette generation in HSL, but do the contrast check with real relative luminance / WCAG ratio for --text vs --bg. even a tiny helper that converts the HSL output to sRGB and checks the ratio would be way less liar-prone.