← Back

A color system from any string

Deterministically turn arbitrary text into a full shade ramp — with enough spread that two different inputs won't quietly end up the same color.

TypeScriptchroma.jsOKLCHColor theoryDesign systems

The problem

On one project, appointment statuses weren't fixed — clinics invented their own ("in room," "awaiting labs," "running late") faster than anyone wanted to maintain a color map for. I needed colors for labels nobody had typed yet, that would never quietly land on the same shade as a status already on screen.

The obvious fix — hash the string, use it as a hue — mostly works, until two unrelated statuses hash into neighboring hues and become indistinguishable at a glance. That gap is what the rejection step below exists to close.

Type any word and hit enter — it becomes a color, deterministically. Try words close to ones already shown to see the rejection step happen.

scheduled
in-progress
completed
urgent

How "urgent" got its hue — each dot is a candidate on the hue wheel, tried in golden-angle steps until one clears the distance threshold from every color already assigned:

→

Same hue, a full shade ramp for UI use (locked hue and chroma, lightness swept):

urgent · 100–900Hover a shade · click to copy

Clean-room rebuild of the core idea for this page — same approach, none of the original code.

How it works

The starting point is a deterministic hash of the input string, turned into an initial hue by stepping around the color wheel by the golden angle (~137.5°) — the same angle sunflowers use to pack seeds without overlap. It's an easy way to get an even-feeling spread of hues from arbitrary numbers instead of clumping.

That alone isn't enough, though — golden-angle spacing reduces collisions, it doesn't prevent them. So each candidate color is checked against every color already handed out using chroma.js's deltaE (a perceptual color-difference metric closer to how human vision actually judges "these look the same" than raw HSL distance). If a candidate is too close to an existing color, the hue steps forward by another golden angle and tries again, up to a handful of attempts before falling back to whatever it last tried.

Once a base color is accepted, generating a full shade ramp is a matter of sweeping lightness at that locked hue and saturation. The production version goes a step further and matches the result against a reference palette to borrow tighter, hand-tuned lightness steps — the version on this page keeps it simple and sweeps lightness directly, which is close enough to show the idea without dragging in a whole reference dataset.

From one color to a palette

The same ramp-building step works the other way round: start from one color someone picked and build everything around it. Here the hues are placed by a color harmony instead of a hash, and the palettes spread from dark to light in OKLCH, where equal steps in lightness actually look equal — unlike HSL, where a yellow and a blue at "50% lightness" are nowhere near each other.

Two details do most of the work. Chroma tapers off towards the light and dark ends, so the palest and deepest colors don't turn neon or muddy. And any shade that falls outside what a screen can show gets its chroma pulled down until it fits, instead of being clipped — clipping quietly shifts the hue.

Drag around the ring, pick a starting color, or shuffle. The harmony arms swing to where the other hues land, and the rest of the palette follows.

#92413f
Start from
Harmony
Crimson NocturneHover a color · click to copy
Crimson Nocturne5 colors
Crimson & Teal5 colors
Crimson Ember5 colors
Amber Twilight5 colors
Crimson Glow5 colors
Ocean Breeze5 colors
Olive Grove5 colors
Ocean Serenity9 colors
Clinic boardToday
In room3 patients
Awaiting labs5 patients
Running late7 patients

Built for this page to show the ramp step on its own. The palette cards are recipes modeled on popular palettes from Coolors, rotated to whatever base you pick. Click any swatch to copy its hex.

What I'd do differently

The rejection-sampling loop needs a hard cap — without one, a pathological run of near-collisions could spin for a while before giving up. Twelve attempts turned out to be more than enough in practice, but I'd rather have that number be a deliberate, documented decision than something implicit that only becomes visible if it ever gets hit.