Okhst: A tone-based color space for predictable UI palettes

tenphi1 pts0 comments

OKHST: predictable color generation for real interfaces — tenphi.me<br>Jul 2, 2026 &middot; 17 min read<br>OKHST: predictable color generation for real interfaces<br>csscoloraccessibilitywebdev<br>Wouldn’t it be great if one hue slider could recolor an entire interface,<br>while a saturation slider controlled how colorful the whole system felt?

I don’t mean a beautiful gradient or a decorative palette. I mean the boring<br>parts of an interface: backgrounds, text, borders, hover states, selected<br>states, disabled states, shadows, dark mode, and high contrast.

The premise sounds simple: pick a hue and saturation, then derive everything<br>else. I chased that idea through several color spaces before finding the<br>assumption that kept breaking it:

lightness is not contrast.

OKHST is the coordinate system I eventually built around that distinction. A<br>CSS playground later in the article tests the model in the browser, while Glaze<br>turns it into a production system.

The Old Attempt

About seven years ago, I tried to generate UI colors at runtime. Instead of<br>hand-picking every token, the framework would take a small set of inputs and<br>derive a complete scheme. HSLuv made the experiment<br>possible.

HSLuv gave me something regular HSL did not: a perceptually useful lightness<br>axis. In regular HSL, 50% lightness can mean wildly different things for<br>yellow, blue, red, and green. HSLuv made “make this lighter” and “make this<br>darker” much closer to operations a UI system could rely on.

I spent months tuning formulas around it. Eventually, tiny declarations could<br>color different parts of an interface while the framework kept the palette<br>consistent. The result was good enough for production, and it was one of the<br>most fun things I had built at the time.

The old demo is still online: theme.tenphi.me

But the system was coupled to one design system, embedded in our framework, and<br>tuned for one product. HSLuv was also still immature, so several inconsistencies<br>needed local workarounds.

The experiment convinced me that UI colors could be computed, but it never<br>became a reusable tool. I kept using HSLuv, stopped shipping generated<br>palettes, and waited for a reason to revisit the idea.

The Web Caught Up

That reason arrived with OKLCH in CSS. It gives the web a modern perceptual<br>color space and makes many color operations more sensible than HSL ever could.<br>But direct palette generation remains difficult because C is absolute chroma,<br>not the relative “saturation” control people expect when authoring UI colors.<br>The maximum possible chroma changes with hue and lightness, so keeping chroma<br>fixed while changing lightness can push some colors outside sRGB and cause<br>clipping.

/* Same chroma, different lightness.<br>Some hue/lightness combinations may clip in sRGB. */<br>.step-1 {<br>color: oklch(35% 0.18 250);<br>.step-2 {<br>color: oklch(65% 0.18 250);<br>.step-3 {<br>color: oklch(85% 0.18 250);<br>Manual palettes can work around this: pick each value by eye and adjust the<br>outliers. Dynamic themes cannot do that so easily. When a background, text<br>color, border, shadow, and hover state all derive from one source color,<br>clipping silently changes the relationship between them. A generated ramp can<br>look fine in the middle and fall apart near the edges.

Modern relative colors make the approach especially tempting:

.card {<br>--bg: oklch(65% 0.18 250);

background: var(--bg);<br>color: oklch(from var(--bg) 25% c h);<br>border-color: oklch(from var(--bg) calc(l - 0.12) c h);<br>The relationship is elegant, but the same absolute chroma follows the<br>background into each new lightness value. A generated palette can therefore<br>drift or clip. What I needed was a saturation axis relative to the maximum<br>chroma available at each hue and lightness.

OKHSL Gets Saturation Right

OKHSL provides that missing axis. It has the familiar shape of hue, saturation,<br>and lightness, but builds it on OKLab/OKLCH math. Its saturation is normalized<br>against what is possible for the current hue and lightness.

Near white and black, where highly saturated colors cannot physically exist in<br>sRGB, OKHSL desaturates gracefully instead of clipping unpredictably.

This graceful behavior is what a generated UI palette needs.

// Illustrative pseudo-code, not a real library call.<br>// Same saturation, different lightness — OKHSL keeps it in-gamut.<br>okhsl({ h: 250, s: 70, l: 20 });<br>okhsl({ h: 250, s: 70, l: 50 });<br>okhsl({ h: 250, s: 70, l: 90 });<br>In OKHSL, 70% saturation means something like:

Use 70% of the chroma available for this hue and lightness.

That is much better for generated UI colors than:

Use this absolute chroma value and hope it still fits.

OKHSL is limited to sRGB, which may sound disappointing in a world of wide-gamut<br>displays. For core UI, I often find that constraint useful: repeated text,<br>icons, and borders benefit from a conservative, in-gamut default. Wide-gamut<br>color still has a natural place in illustrations, decorations, and brand moments.

The Bug in My Mental Model

OKHSL solved the gamut problem, but...

color lightness okhsl saturation oklch chroma

Related Articles