← Back
[W] Writing

A theme from three colours

Pick a blue and a yellow in HSL with the same lightness, 50%. The blue is a deep, dark blue. The yellow is nearly as bright as white.

The numbers say they match, and your eyes say they're nowhere close. Every colour system has to deal with that gap before it can generate anything.

Both columns use the same 12 lightness numbers. The left reads them as HSL lightness, the right as OKLCH lightness. Start on Yellow, then try Blue, and watch the left column's numbers drift while the right one holds.

HSL lightness is a formula, not a measurement

HSL was designed to be cheap to compute from RGB. Its lightness is the average of the largest and smallest channel. For pure yellow, 255, 255, 0, that's 50%. For pure blue, 0, 0, 255, it's also 50%.

But green light looks far brighter to us than blue light. The WCAG relative luminance formula weights the channels to match: 0.2126 for red, 0.7152 for green and 0.0722 for blue. Yellow has red and green, so it's bright. Blue has only the weakest channel.

So against white, HSL yellow at 50% has a contrast of 1.07:1. HSL blue at 50% has 8.6:1. Same "lightness", one unreadable and one fine.

A perceptual space fixes this by measuring lightness as people report it. In OKLCH, that yellow is L 0.97 and that blue is L 0.45. Those numbers agree with your eyes. OKLCH has three parts: L for lightness, C for chroma (how colourful), and H for hue angle. Change one and the other two stay put, to a good approximation.

That's why Linear rebuilt its theme generator on LCH, a perceptual space, instead of HSL. In their words, it means "a red and a yellow color with lightness 50 will appear roughly equally light to the human eye."

What the 12 steps are for

A good scale isn't 12 evenly spaced colours. Each step has a job, and its lightness follows from the job.

Radix Colors is the clearest public example. Every scale has 12 steps with fixed meanings:

1–2App background, subtle background (cards, sidebars)
3–5UI element background: normal, hover, pressed or selected
6–8Borders: subtle, interactive, hovered and focus rings
9–10Solid background, and its hover. The purest colour in the scale
11–12Low-contrast text, high-contrast text
10 lightness share of chroma 123456789101112 BgsComponentsBordersSolidText
The curve the demo uses, in OKLCH lightness. Backgrounds sit close together near white, because small differences are enough there. Then it drops fast toward text, which needs distance from the background.

The shape is the design. Steps 1 to 8 are close together because they live behind content, and a background that jumps too much looks like a mistake. Steps 11 and 12 are far away because text has to be read.

Radix also states what the text steps guarantee. Step 11 reaches APCA Lc 60 and step 12 reaches Lc 90 on step 2 of the same scale. More on APCA below.

Generating a scale

Once lightness means something, a generator is short. Hold the hue. Walk lightness down the curve. Give each step a share of the chroma.

const L = [0.993, 0.978, 0.951, 0.922, 0.889, 0.849, 0.795, 0.722, 0.64, 0.6, 0.52, 0.32];
const share = [0.06, 0.12, 0.22, 0.32, 0.42, 0.52, 0.64, 0.8, 1, 1, 0.9, 0.55];

const scale = L.map((l, i) => fitToSrgb(oklch(l, chroma * share[i], hue)));

The chroma share is the part people miss. Near white and near black there's very little room for colour. A screen can't show a vivid colour that's also almost white. So chroma has to ease off at both ends, peak at the solid step, and fall again for text.

Even then, some steps won't fit, and fitToSrgb lowers chroma at the same lightness and hue until they do. The demo tells you how many it had to touch.

Hue makes this harder than it looks. Every hue reaches its most vivid colour at a different lightness. Pure sRGB yellow peaks at L 0.97, while pure blue peaks at L 0.45. A yellow step 9 at the same lightness as a blue one can't be very yellow. That's why real palettes still tune some hues by hand, and why yellow is usually the scale that needs dark text on its solid colour.

Three inputs, every surface

Once you can generate a scale from a hue, you can generate a theme from very little. Linear's 2024 redesign moved its own light and dark themes onto its generator: "instead of having to define 98 specific variables for each theme, we defined three: base color, accent color, and contrast." Radix's custom palette tool also takes three: accent, gray and background.

Here's a sketch of the idea. It's not Linear's code, which isn't public, but it works the same way: every colour is a lightness on a curve, with hue and chroma borrowed from an input.

The base colour tints every neutral. The accent drives the button, link and selection. Contrast pushes text and borders away from their backgrounds. Try a warm base, or a yellow accent and watch the button text flip to black.

Contrast is a single number, but it moves many things in different directions. In light mode it darkens text and borders. In dark mode it lightens them and also darkens the backgrounds. That's how one slider can produce a high-contrast theme without anyone hand-picking 98 values.

Measuring contrast

The demo's numbers use the WCAG 2 contrast ratio. Take each colour's relative luminance, add 0.05 to both, and divide the larger by the smaller. WCAG 2 asks for 4.5:1 for body text and 3:1 for large text.

const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);

It's simple, and it's the standard the law and most audits point to. But it treats a pair the same whichever colour is the text, and it's known to be generous with dark colours, rating some dark pairs as passing that are hard to read.

APCA is the main proposed replacement. It models perception more closely, gives a lightness contrast value (Lc) rather than a ratio, and gives different results for dark-on-light and light-on-dark. Radix uses it for those step 11 and 12 guarantees.

Its status is the catch. WCAG 3 is still a Working Draft. APCA was taken out of the draft in 2023, and the current editor's draft says the contrast algorithm "is yet to be determined". So I'd use APCA as a design tool and WCAG 2 as the bar you're actually held to.

The real value of a generated theme isn't fewer variables. It's that the rules are written down. When contrast is a curve instead of 98 separate choices, you can check every theme against it, including the ones your users make.

Next