A tiny HSL theme store that keeps gradients and contrast in sync

Yo folks, I’m wiring up theming in a small dashboard and I wanted one place to own color state so components don’t drift. The failure mode I keep hitting is mutable shared refs: one widget tweaks a palette and suddenly the whole app’s contrast is off.

// single-owner theme state: base HSL -> derived palette + gradient + contrast
export function createThemeStore(initial = { h: 210, s: 70, l: 45 }) {
  let base = { ...initial };
  const subs = new Set();

  const clamp = (n, a, b) => Math.min(b, Math.max(a, n));
  const hsl = ({ h, s, l }) => `hsl(${((h % 360) + 360) % 360} ${clamp(s,0,100)}% ${clamp(l,0,100)}%)`;
  const relLum = ({ h, s, l }) => {
    // quick-ish approximation: use l + a little saturation bias
    const L = clamp(l, 0, 100) / 100;
    const S = clamp(s, 0, 100) / 100;
    return clamp(L * 0.9 + S * 0.1, 0, 1);
  };

  const derive = (b) => {
    const steps = [-18, -10, 0, 10, 18].map((dl) => ({ ...b, l: clamp(b.l + dl, 0, 100) }));
    const palette = steps.map(hsl);
    const gradient = `linear-gradient(135deg, ${palette[1]}, ${palette[3]})`;
    const text = relLum(b) > 0.55 ? "hsl(0 0% 10%)" : "hsl(0 0% 98%)";
    return Object.freeze({ base: Object.freeze({ ...b }), palette, gradient, text });
  };

  let theme = derive(base);

  const setBase = (patch) => {
    base = { ...base, ...patch }; // no external refs
    theme = derive(base);
    subs.forEach((fn) => fn(theme));
  };

  return {
    get: () => theme,
    set: setBase,
    subscribe(fn) { subs.add(fn); fn(theme); return () => subs.delete(fn); }
  };
}

Neat part is everything derived stays consistent (palette, gradient, contrast text) and consumers only ever see frozen snapshots, so state ownership is obvious and accidental mutation doesn’t silently wreck the theme.

Freezing the snapshots is a really clean move here — it makes “who owns theme state” obvious and stops the spooky action-at-a-distance bugs.

The only part I’d side-eye is relLum. That L * 0.9 + S * 0.1 is gonna mis-rank stuff like saturated blue vs yellow even when l matches, so your text color will randomly flip in the “why is this unreadable” cases. I’d bite the bullet and do the actual WCAG-ish luminance (HSL → sRGB → linearize → relative luminance), even if it’s a tiny helper.

Naming: I’d just call setBaseset, and I like the idea of letting set take either a patch or an updater fn so you can do set(t => ({ l: t.l + 5 })) without a get() dance.

Freezing the snapshots is smart — I’ve watched “just tweak this one color real quick” turn into three different theme forks in a codebase, and nobody notices until the UI looks like a ransom note.

The only part I’d push on is relLum: it’s basically “lightness-ish,” so that 0.55 cutoff is gonna feel weird when someone drags hue around (yellow reads bright way earlier than blue at the same l). I’m not sure you need to go full WCAG police for a tiny dashboard, but swapping that decision to a real sRGB relative luminance (convert HSL → RGB → luminance) would make the text flip feel a lot less random.

Yeah, the “lightness-ish” cutoff is the only part that’ll bite you in practice — HSL l just doesn’t track perceived brightness once you start swinging hue around, so the text flip will feel arbitrary (yellow/green especially).

If you want a drop-in fix, swapping relLum to actual sRGB relative luminance (HSL → RGB → linearize → 0.2126/0.7152/0.0722) is the right direction, and it doesn’t mean you have to go full WCAG spreadsheet mode for a tiny dashboard. If you want a reference implementation, kirupa has a solid walkthrough on relative luminance + contrast math you can lift from: https://www.kirupa.com/html5/relative_luminance.htm