Skip to content

Documentation

How it works

bezold answers questions about colour that have a right answer: how a scale is generated, how saturated a hue may be before it shouts, and which values clear a given contrast bar against a given background. Everything below is what it does and why — with the numbers taken live from the engine rather than typed into the page.

The scale

A palette is twelve steps: 20 through 950. They are generated in HSLuv, and the whole system rests on one property of it — HSLuv’s L is CIE L*, which has an exact relationship to relative luminance. So L 60 at hue 30 is the same perceived lightness as L 60 at hue 250, and the same measurable contrast against the same background.

That is what makes a contrast assignment portable. A rule written for a blue palette holds for a yellow one, because the two palettes’ step 600 is the same lightness. Without it, every hue needs its own table.

The lightness ladder

The base ramp is L = 100 − step/10 — a straight line from white to black. Five steps deviate, and every deviation is a bar being cleared rather than a taste:

StepL*BaseWhy it moved
1009290Against step 950 both polarities sat a point under the body bar. At 91.2 both clear Lc 90.
20082.480Gradation, not a bar. The 100 bend stretched the 100/200 gap to 11.2 L*; at 81 the gaps rebalance to 10.2 / 9.
3007270At L 70 a dark label on a 300 fill reaches Lc 58, putting the step out of reach for buttons. The worst hue needed 71.6.
40058602.96:1 against step 700, a hair under SC 1.4.11. The legal window is under half an L* wide.
50049.450A white label measured 4.46–4.51:1 — four hundredths under WCAG. At 49.4 it reads 4.6:1 at every hue.

Because HSLuv L is CIE L*, each of those thresholds is a single line on the lightness axis — the same for every palette — so they are solved once rather than per hue. The unbent steps are unbent because nothing binds near them.

Tonal variants

Three, and they are positions on one saturation axis rather than three separate treatments: neutral at level 18, pastel at 50, vibrant at 88. Pastel sits where it does because that is the perceptually even position on the axis, not the arithmetic midpoint.

One invariant holds across all three: pastel may never out-chroma vibrant. It is the sort of rule that sounds obvious and stops being true the moment compensation curves are tuned per variant.

Saturation compensation

Equal saturation does not look equal. Some hues appear more chromatic than others at the same nominal value, and some are simply not available at full chroma in sRGB. Compensation runs in four layers, in a fixed order, because they interact:

  1. Hue-window curves — half-sine windows over the ranges that need pulling back, times a lightness ramp, times a per-variant strength.
  2. The tuning mesh — per-hue, per-step multipliers carrying QA verdicts, eased smoothly and circularly around the wheel so there are no spikes anywhere.
  3. The vibrancy envelope — the approved chroma at every (hue, lightness) as a smooth local interpolation of the node palettes’ actual chromas. Anything above its local surface is eased onto it.
  4. The perceived-brightness knee — runs last. Layers 1–3 equalise chroma; nothing equalised perceived brightness, and at step 500 the Helmholtz–Kohlrausch ΔL across twelve hues spanned 26 units.
RangeHuesMax reductionCharacter
yellow55105°35The most aggressive, and it bites only at high lightness.
green95160°25A fluorescent quality that wants pulling back.
cyan150205°15Similar to green, less severe.

The Helmholtz–Kohlrausch effect and “yellow looks fluorescent” are two different things at opposite ends of the wheel. Both analytical H-K models put their minimum at yellow — H-K is a dark-blue and violet phenomenon. What reads as fluorescent yellow is sRGB gamut availability, which the envelope handles as geometry.

Two standards, enforced together

WCAG 2.x and APCA are both checked, and not out of caution. They bind in different places: WCAG binds every light-mode UI-background row — APCA alone would allow a step or two lighter — and APCA always binds in dark mode, where WCAG never does. Either one alone ships something the other would have caught.

RoleAPCAWCAGWhat it covers
HeadingsLc 603:1Large text. A floor, not the assignment — taking the lightest passing step lands headings lighter than the body beneath them.
Non-body textLc 754.5:1Labels, values, and text sitting on a filled control.
UI & boundariesLc 453:1Non-text: control edges, field strokes, focus rings, chart fills.
Large bodyLc 754.5:118px/400, 16px/500, 14px/700 or 24px/300.
Body textLc 904.5:1Anything Bronze does not list at the lower tier — including 16px/400.

The APCA target is Bronze. Bronze names Lc 90 for body generally and Lc 75 as the minimum for 18px/400, 16px/500, 14px/700 or 24px/300. Note what is missing from that list: 16px/400 is not on it, and so falls back to Lc 90 — which is the single most common body setting on the web, and the reason so many sites that “pass” do not.

Worst case, across the calibration hues

Every legal set the engine publishes is the worst figure achieved across 11 calibration hues — not the figure your particular palette achieves. That is deliberate and it is what makes the table portable.

The consequence worth stating plainly: a specific palette often does better than the floor, and you must not narrow the floor by re-measuring one hue to gain a step. That is exactly how a system-wide rule degrades into a per-palette accident — the assignment works for the hue it was measured on and quietly fails for the next one.

The measured basis: rotating hue anywhere on the wheel changes contrast by at most 0.43 Lc. Hue is not a contrast lever. Lightness is, and the ladder is fixed.

The five bands

Once both standards are applied, a twelve-step scale splits into five bands, and three of the steps in the middle can carry no body text in either direction. They are too dark for dark ink and too light for light ink. This is a property of perceptually uniform scales in general, not of this one.

StepsRoleInk
20, 50Lc 90 pagesdark ink
100Gated Lc 75 pagedark ink
200–500No body text, either direction
600Mirrored gated Lc 75 pagelight ink
700–950Lc 90 pageslight ink

Brand colours

A brand hex rarely lands on a step. The engine answers two different questions about it: where it sits (nearest by relative luminance, ties to the darker step) and where it may pin — which is not always the same place.

For a value to replace a step it has to meet all of that step’s accessibility restrictions. Pure red sits at L* 53.2, which lands on step 500, and pinning it there leaves a white button label at 4.0:1 — the 4.5:1 promise step 500 exists to keep is gone. So the claim search walks outward from the nearest step, simulates the full redistribution and bend re-solve for each candidate, and takes the first that loses no guarantee.

Pinning redistributes the whole ladder: both sides rescale proportionally with the endpoints fixed. Every lightness moves, which means the published contract stops describing the palette — anchored ladders are measured directly instead.

Why HSLuv and not OKLCH

The received advice is that OKLCH is the modern choice. That is true for hand-authored CSS and wrong for a generator, for two measured reasons.

OKLab’s L has no exact link to relative luminance. Two in-gamut colours at identical OKLCH L 0.65 / c 0.15 (h 250 versus h 145) measure Y 0.2748 and 0.2932 — white-contrast 3.23:1 against 3.06:1. The same experiment at HSLuv L 50 gives Y 0.1836 and 0.1841, a difference indistinguishable from hex rounding. Contrast guarantees have to be exact or they are not guarantees.

Constant-chroma OKLCH ramps leave sRGB. At c 0.15, hue 110 is out of gamut by L 0.35 — which forces gamut-mapping decisions that break exact brand anchoring and byte-level determinism. HSLuv’s saturation is gamut-relative, so every triple it produces is in gamut by construction.

OKLCH is still used, for what it is good at: hue geometry. Scheme angles and hue detection run in OKLCH, because it spaces hues more honestly than HSL does. Generation model and output syntax are separate concerns — the computed values could be serialised as oklch() strings without touching the model.

Okhsl, and the fallback that was never needed

Okhsl is Ottosson’s HSLuv-analogue on Oklab, and it inherits the same non-exact link to Y. Measured at fixed l 0.5 / s 0.9 across twelve hues, WCAG Y spreads 0.0445 — twenty times HSLuv’s hex-rounding residue. White-text contrast ranges 4.22–5.14:1, so AA passes on seven hues and fails on five at the same nominal lightness. Hue-invariant assignment breaks.

A legitimate alternative was prototyped and works: Y-pinned tone mapping, solving Okhsl l per hue by bisection to hit an exact target Y. That is HCT’s architecture. It was not adopted because it trades a one-line auditable guarantee — “L is CIE L*” — for a solver, and because its main benefit is what the compensation layer already targets. It remains the named fallback, with a named trigger, and the trigger has not fired.

Why not just use HCT

Most of the work a colour engine does is space-independent: the tonal variants are design opinions, the hue shifting is a feature, and the visual QA is the visual QA. Only the H-K compensation could shrink under HCT — and Material did not escape that work either; they tuned tone-dependent chroma curves and froze them into the library, which is also why HCT palettes carry a recognisable Material look.

HCT is used here as the yardstick instead: QA renders HCT tonal palettes beside bezold’s as the quality benchmark.

Determinism

Same input, byte-identical output. No Math.random(), no date-dependent values, no floating solver whose iteration count varies. Where a solve is needed it runs a fixed number of bisection steps. This is why a palette response caches by input indefinitely and why snapshot tests are worth having.

Reading the generator

Everything above is what the grid on the home page is showing. Each row is one bar from the table; each pill is the contiguous run of inks that clears it on that page; each segment is a step you can hover for its exact worst-case figures.

Back to the generator →