Skip to content

Documentation

How it works

bezold answers questions about color 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. Seven steps deviate, and every deviation is a bar being cleared rather than a taste:

StepL*BaseWhy it moved
5095.695Lifted so step 400 can carry a boundary on a step-50 page, and no further, so step 20 stays visible against it.
1009290Carries body text against step 950, both ways round.
20082.680Carries the light buttons: a black label, and step 950 at the large-body bar both ways, under every saturation lift.
3007270The boundary step: a mark on a step-700 ground.
4005860Holds a boundary on the step-50 page. Its other constraints could not hold beside that one, so they were dropped.
50048.750Carries a white button label at both standards’ bars, at every hue and every saturation lift.
70029.630Carries body text on white.

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 12, 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. Without it, step 500 in vibrant spans 16.0 units of Helmholtz–Kohlrausch ΔL across twelve hues.

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.

WCAG required, APCA advisory

WCAG 2.2 is the requirement: it is what conformance is audited against, and no legal set holds an ink that fails it. APCA is advisory: it is the better predictor of whether text can be read, but no standard requires it yet. bezold checks both anyway, and not out of caution. They decide in different places: WCAG decides some light-mode UI-background rows — APCA alone would allow lighter inks there — and APCA decides most rows in dark mode, where WCAG never does. Either one alone ships something the other would have caught. So the contract lists, for each bar, every ink that clears WCAG, with the lowest opacity at which it also clears APCA, or none where it never does.

RoleAPCAWCAGWhat it covers
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:114px/700, 16px/500, 18px/400, 24px/300.
Body textLc 904.5:1Anything Bronze does not list at the lower tier — including 16px/400.

Headings are held by their size and weight, as body text is. WCAG’s lower ratio is only for large text, from 24px, or from 18.66px at weight 700. A smaller heading needs what body text needs. The advisory APCA bar is body’s rung for the same size and weight. Both are minimums, not the assignment: taking the lightest step that passes lands headings lighter than the body beneath them. APCA’s ceiling, Lc 90 at AA and Lc 85 at AAA, is advisory.

Heading sizeWeightAAAAA
14–15px700 and heavier4.5:1, Lc 757:1, Lc 90
14–15px400 and heavier4.5:1, Lc 907:1, Lc 100
16–17px500 and heavier4.5:1, Lc 757:1, Lc 90
16–17px400 and heavier4.5:1, Lc 907:1, Lc 100
18px400 and heavier4.5:1, Lc 757:1, Lc 90
18px300 and heavier4.5:1, Lc 907:1, Lc 100
18.66–23px700 and heavier3:1, Lc 754.5:1, Lc 90
18.66–23px400 and heavier4.5:1, Lc 757:1, Lc 90
18.66–23px300 and heavier4.5:1, Lc 907:1, Lc 100
24px and up300 and heavier3:1, Lc 754.5:1, Lc 90
24px and up200 and heavier3:1, Lc 904.5:1, Lc 100

The APCA target is Bronze. Bronze names Lc 90 for body generally and Lc 75 as the minimum for 14px/700, 16px/500, 18px/400, 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 whole wheel

Every legal set the engine publishes is the worst figure achieved across 369 hues — all 360 whole degrees of the wheel and the nine calibration hues between them — in all three variants on both the ink and the surface, and across every pairing of an ink hue with a different surface hue on a 10° grid. It is 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 2.2 Lc, measured at build over every whole degree, in all three variants, for every step against white and black. 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 two 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–100Lc 90 pagesdark ink
200Gated Lc 75 pagedark ink
300, 400No body text, in either direction—
500, 600Gated Lc 75 pageslight ink
700–950Lc 90 pageslight ink

Brand colors

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 3.99: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.

Every color here is an sRGB hex, made for screens. A printing press reproduces less than a screen does, and saturated blues, violets, greens and cyans are what it loses first. Print-safe takes a palette made to meet WCAG and APCA contrast standards and fits it to what a printing press can reproduce, based on the ISO 12647-2:2013 standard for offset printing on coated paper, keeping each color within a reasonable range of its screen original.

What it checks against. Each color is checked against a measured coated press: the data Fogra publishes for offset printing on premium coated paper, FOGRA51. A color counts as printable when the press can reproduce it within a set tolerance, the most a printed color may differ from the color asked for, and the tolerances on offer are chosen to meet industry standards. Print-safe starts at the tightest of them.

ISO 12647-2:2013, Graphic technology — Process control for the production of half-tone color separations, proof and production prints — Part 2: Offset lithographic processes. It is cited for the press condition it describes. The press data is Fogra’s, and FOGRA51 names that data and nothing more. bezold is not certified against the standard or the data, and not endorsed by ISO or by Fogra.

Saturation is what moves. Hue never does. Lightness stays on the ladder above and never moves to gain color: only to keep a contrast bar, or to bring a brand color into print when you ask, and then by no more than 0.5 L*. Where a step is more saturated than the press can hold, its saturation comes down, and a neighbour may come down with it so the ramp keeps its shape. The result is still a palette from the same engine.

A press asked for a color it can’t make prints the nearest thing it can, and that is usually darker. Following it darker would break contrast, so a print-safe step can look less rich than its screen color would printed as it is. At the screen’s lightness, the press can’t hold more.

Contrast is checked, not assumed. Every print-safe color must clear every contrast bar its screen color cleared, against white, black and every other step in the theme. A step that can’t keeps its screen color and is flagged. Because the check reads the whole theme, adding a family can change another family’s print colors.

The dark end. Every press has a darkest color it can print, and the darkest steps sit below it. They keep their lightness and are flagged. Steps 900 and 950 are flagged on every press.

Brand colors. A color you type is never changed without your say. If the press can’t reproduce it at the tightest tolerance, the whole palette is checked at the tightest one that takes every brand color, because one print job runs on one press, and the generator tells you it did. A brand color the press can’t reach even then keeps its exact hex and is reported as a color that will shift in print. With Make brand colors print-safe ticked, it becomes the printable color that looks closest to the one typed, at the same hue and within 0.5 L* of its lightness. Either way, contrast gives way around it rather than moving it.

What it doesn’t promise. The check models the standard press condition, not a physical print, so it says what that condition should reproduce, not what a given print will look like. A printer on another paper or press will get different results.

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 colors 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.1852 and 0.1855, 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 in gamut only from L 0.68 to L 0.98 — outside that, the ramp 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 — more than twenty times HSLuv’s hex-rounding residue. White-text contrast ranges 4.22–5.14:1, so the 4.5:1 AA bar 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 color 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 →