A faithful OKLCH (and LCHuv, LCH, HCT) implementation of Wijffelaars, Vliegen, van Wijk & van der Linden, "Generating Color Palettes using Intuitive Parameters" (Computer Graphics Forum 27:3, EuroVis 2008), meant to cover more or less the same API as RampenSau.
The paper generates sequential and diverging palettes from a few intuitive parameters by walking a quadratic-Bézier path through the gamut triangle (black · cusp · white) of a hue. It was written for CIELUV; this is that exact model re-expressed in OKLCH — or run in the paper's own CIELUV (LCHuv), in CIE LCH, or in Material's HCT — targeting sRGB or Display-P3.
For a fixed hue, the displayable colors approximate a triangle in the chroma–lightness plane with corners at black, white, and the cusp — the most saturated color that hue can reach (the paper's MSC). A palette is a quadratic-Bézier path through that triangle, sampled at perceptual lightness steps. The cusp / triangle being an inner approximation of the gamut keeps the result displayable.
The paper's lightness curve is evaluated in its native CIE L* units. In OKLCH it is converted to OKLab lightness through luminance Y (for neutrals OKLab L = Y^⅓ exactly), so the palettes hit the same physical lightnesses the paper calibrated against the Brewer palettes. LCHuv, LCH and HCT all measure lightness as L* (HCT calls it tone), so there it applies as-is.
Diverging palettes sample the joined two-arm curve uniformly: odd N lands on the combined
neutral exactly once; even N straddles it at half-step spacing, so the step across the join reads
like every other step (under the default spacing — a non-linear lEasing eases each arm from its
dark end to the neutral, and trades that uniform join step for its own). The neutral is symmetric
in the two arms (swapping hStart/hEnd mirrors the palette, including with coolWarm).
The knobs are the paper's:
saturation(s) — the curve's tension.0is a gray ramp;1bends the path through the cusp.brightness(b) andcontrast(c) — shape the lightness sampling (L(t)).coolWarm(w) — multi-hue shift that pulls the light end toward yellow (Table 2).
npm install cusphanger nutelchGamut math is delegated to nutelch (≥ 0.3, LUT-backed, runtime dependency-free), so you pass the gamut LUT in (just like nutelch). The LUT picks both the space and the gamut:
| LUT | space | gamut | l range |
|---|---|---|---|
oklchSrgb |
OKLCH | sRGB | 0..1 |
oklchP3 |
OKLCH | Display-P3 | 0..1 |
lchuvSrgb |
LCHuv (CIELUV, the paper's) | sRGB | 0..100 |
lchuvP3 |
LCHuv (CIELUV, the paper's) | Display-P3 | 0..100 |
lchSrgb |
CIE LCH (CIELAB) | sRGB | 0..100 |
lchP3 |
CIE LCH (CIELAB) | Display-P3 | 0..100 |
hctSrgb |
HCT (from nutelch/hct) |
sRGB | 0..100 (tone) |
hctP3 |
HCT (from nutelch/hct) |
Display-P3 | 0..100 (tone) |
HCT LUTs come from nutelch's add-on entry: import { hctSrgb } from 'nutelch/hct'. The space
support needs nutelch 0.3 or later (earlier versions ship only the OKLCH and LCH LUTs, and
no LCHuv or HCT).
In the demo, the model | gamut picker next to the tabs switches between all eight LUTs; the figures then draw that space's own gamut. The paper's space is marked (paper) in the list.
What the paper says. Wijffelaars et al. wanted a perceptually uniform space and chose CIELUV,
noting that CIELUV is recommended for additive light (screens) and CIELAB for reflected light
(print). They are frank that CIELUV is only approximately uniform: none of the CIE distance
formulas gave satisfying results, so they fitted their own lightness function L(t) to the
Brewer palettes, on calibrated CRT monitors.
What we recommend.
- OKLCH (default): the best fit for the model, and the most practical. The triangle the
paper's model rests on (black · cusp · white) follows the OKLCH gamut as closely as it follows
CIELUV's (table below). OKLab was designed to keep hue steady as chroma and lightness change,
which is the known weak spot of CIELAB/CIELUV blues. And the output is native CSS
oklch(). - LCHuv: when you want the paper as published. Same space, and
L(t)is exactly the curve they calibrated. The triangle fits as well as in OKLCH. CSS has no LUV syntax, so colors are rendered as the equivalentlch(). - LCH: when your pipeline is CIELAB / CSS
lch(). The triangle leaves about 15% of the available chroma unused, so palettes come out a little less colorful than in OKLCH or LCHuv. - HCT: when you need Material's tone semantics, where a tone difference guarantees a
WCAG contrast ratio. It's the weakest fit for this model: HCT's gamut slices aren't
triangle-shaped, so the triangle leaves about a quarter of the chroma unused, and where it
overshoots, the clamp has to pull back hard. It also needs
nutelch/hct.
How closely the paper's triangle follows each gamut (sRGB LUTs, hue every 2°, lightness every 1%). Chroma reached is the share of the available chroma the triangle gets to (after clamping). Overshoot is where the triangle pokes outside the gamut and gets clamped:
| space | chroma reached | overshoot: how often | overshoot: by how much |
|---|---|---|---|
| OKLCH | 99.6% | 21% | ~12% |
| LCHuv | 99.2% | 20% | ~11% |
| LCH | 85.4% | 22% | ~13% |
| HCT | 77.1% | 12% | ~39% |
Results are typed PaletteColor (mode: 'oklch' | 'lchuv' | 'lch' | 'hct') instead of
OklchColor, since the space now follows the LUT. With an OKLCH LUT nothing changes at runtime —
the output is identical — but code that annotates results as OklchColor[] needs a cast or the
wider type. OklchColor is still exported.
import { sequential, ramp, diverging, fromColor, cubicBezier } from 'cusphanger';
import { oklchSrgb, oklchP3, lchuvSrgb, toCss } from 'nutelch';
// single-hue sequential (paper, Table 1)
sequential({ hStart: 260, total: 9, saturation: 0.6, brightness: 0.75, contrast: 0.88, lut: oklchSrgb });
// cool/warm multi-hue (Table 2)
sequential({ hStart: 260, total: 9, coolWarm: 0.15, lut: oklchSrgb });
// diverging — two sequentials joined through a shared neutral
diverging({ hStart: 250, hEnd: 30, total: 9, lut: oklchSrgb });
// Display-P3 target — just pass the P3 LUT
sequential({ hStart: 260, total: 9, lut: oklchP3 });
// lightness by endpoints instead of brightness/contrast (RampenSau-style lRange)
sequential({ hStart: 260, total: 9, lRange: [0.25, 0.95], lut: oklchSrgb });
// the paper's own space: LCHuv (CIELUV). Same options; lRange is in L* (0..100)
sequential({ hStart: 260, total: 9, lRange: [25, 95], lut: lchuvSrgb });
// redistribute the samples along the lightness curve (see "Lightness spread");
// works on sequential(), diverging() and ramp()
sequential({ hStart: 260, total: 9, lEasing: cubicBezier(0.4, 0, 0.8, 0.6), lut: oklchSrgb });
// ramp() — the RampenSau hybrid: a hue trajectory through the paper's model
// (each color rides the paper's ramp for its own rotated hue)
ramp({ hStart: 260, total: 9, hCycles: 0.3, lut: oklchSrgb });
// saturation as a (gamut-relative) range that varies across the ramp
ramp({ hStart: 260, total: 9, sRange: [0, 1], lut: oklchSrgb }); // gray dark → vivid light
// an explicit hue per color (RampenSau-style hueList) — pairs with RampenSau's
// uniqueRandomHues / colorHarmonies. Overrides total and the hue trajectory.
ramp({ hStart: 0, total: 9, hueList: [10, 120, 240], lut: oklchSrgb });
// fromColor() — the inverse: solve the model so the palette meets a color you
// already have (see "fromColor" below)
fromColor({ mode: 'oklch', l: 0.58, c: 0.09, h: 155 }, { total: 9, lut: oklchSrgb });sequential() and diverging() are the paper's surface plus a few opt-ins: lEasing on both
(see Lightness spread) and sRange/sEasing per diverging arm.
Leave them unset and you get the paper's model, exactly. ramp() is the RampenSau-shaped entry
point: RampOptions extends SequentialOptions with the hue trajectory (hCycles,
hStartCenter, hEasing, hueList), ramped tension (sRange/sEasing) and triangleMode;
with none of them set it equals sequential() exactly. Option names follow
RampenSau's conventions where they correspond (total, hStart/hEnd); the paper-specific knobs
keep their own names. Defaults follow the paper: saturation = 0.6, brightness = 0.75,
contrast = min(0.88, 0.34 + 0.06·total), coolWarm = 0.
Lightness — two equivalent knobs. brightness/contrast are the paper's b/c; lRange: [minLight, maxLight] sets the two endpoints directly (RampenSau-style), in the LUT's lightness
units (0..1 OKLCH, 0..100 LCHuv / LCH / HCT tone), and wins when given. They're
a bijection — the same lightness curve, with the paper's perceptual 0.2^x spacing kept between the
endpoints either way.
Each color is the nutelch / culori-native object in the LUT's space (PaletteColor):
{ mode: 'oklch', l, c, h } // with an OKLCH LUT
{ mode: 'lchuv', l, c, h } // with an LCHuv LUT (likewise 'lch', and 'hct' with tone in l)It's in-gamut by construction (clamped to the LUT's shell). To render it, hand it to nutelch's
toCss — the browser renders oklch() / lch() natively and gamut-maps to the display. CSS has
no LUV or HCT syntax, so LCHuv comes out as the equivalent lch() and HCT (use the toCss from
nutelch/hct, which handles every mode) as the equivalent oklch():
el.style.background = toCss(palette[0]); // 'oklch(0.44 0.13 260)' — or 'lch(…)' for LCHuvFor a hex string or gamut flags (interchange, contrast math), use culori:
formatHex(color), inGamut('rgb')(color).
Also exported, both taking a nutelch LUT: cusp(hue, lut) (the MSC apex) and
maxChromaAt(hue, l, lut) (the gamut shell at a lightness).
The inverse problem: you have a color (a brand green, a chart accent) and want the ramp that
passes through it. fromColor solves the sequential model for it and returns options, not
colors, so the solve stays inspectable and tweakable:
import { fromColor, sequential } from 'cusphanger';
import { oklchSrgb } from 'nutelch';
const target = { mode: 'oklch', l: 0.58, c: 0.09, h: 155 };
const { options, index, color, clamped } = fromColor(target, { total: 9, lut: oklchSrgb });
const palette = sequential(options);
palette[index]; // === target (exactly, when reachable)Nothing is fitted — the constraints decouple. The hue is taken exactly (hStart = target.h, the
whole curve lives in the target's hue plane), saturation is bisected until the curve's chroma at
the target's lightness matches (as s goes 0 → 1 the curve sweeps from the gray axis out to the
triangle edges, so that chroma only grows), and the lightness endpoints shift minimally so sample
index lands on the target's lightness exactly.
index— which palette entry carries the target. Defaults to'nearest'(the sample whose default-spacing lightness is closest, so the endpoints move least); pass a number to pin it.lRange— hold the lightness endpoints, and only hue + tension are solved. The continuous curve still passes through the target;indexthen reports the nearest sample.lEasing— hold a lightness easing.indexand the endpoint solve are judged against the eased spacing and the easing is handed back inoptions, so the target still lands on a sample. Adding anlEasingto already-solved options instead moves the samples off the target (it stays on the continuous curve) — pass it tofromColor, not after it.clamped— reachability is the triangle (∩ the shell), not the full gamut. An unreachable target never throws: it is met at the same-lightness boundary point instead, returned ascolor, withclamped: true.coolWarmis deliberately absent —w > 0drifts hue along the curve, which breaks the hue decoupling. It is held at 0.
The target is the same object the generators emit, in the LUT's space — an oklch target for
an OKLCH LUT, lchuv for an LCHuv one, and so on; a mismatch throws. No color parsing or
conversion ships in the library; a hex or CSS string is one culori call
away: converter('oklch')('#4a8a62') (or 'lchuv' / 'lch'). culori has no HCT; for an
HCT target use nutelch's rgbToHct (nutelch ≥ 0.4):
rgbToHct(converter('rgb')('#4a8a62')). The demo's from color field is this solve, live: it
snaps the sliders to the returned options and rings the sample that carries the color.
lEasing (on sequential(), diverging() and ramp()) is deliberately not a free lightness
curve. It eases t before the paper's 0.2^x spacing, so the lightness curve and its endpoints
(lRange / brightness / contrast) stay exactly as the model built them — only where the
samples fall along that curve moves. A linear easing is the paper's spacing, exactly.
import { sequential, cubicBezier } from 'cusphanger';
import { oklchSrgb } from 'nutelch';
sequential({ hStart: 260, total: 9, lEasing: cubicBezier(0.4, 0, 0.8, 0.6), lut: oklchSrgb });cubicBezier(x1, y1, x2, y2) is a CSS-style easing with the y handles clamped to [0, 1], i.e.
monotone by construction; it is what the demo's spread editor drives. Any (t) => number works,
including RampenSau's easing helpers. What to know:
- It spends the paper's calibration. The
0.2^xspacing is the part fitted against Brewer so neighboring classes stay distinguishable. Bunching steps is fine for a UI scale, less so for a choropleth — leavelEasingunset for data classes. - Ordered and in gamut, whatever you pass. The output is clamped to
[0, 1]and kept non-decreasing, so a bad easing can never reorder the ramp or leave the gamut. It can bunch steps, and a flat stretch repeats a color. - Same path only while the hue holds still. Hue and tension follow the un-eased
t. Insequential()and on eachdiverging()arm every eased color is a point on the paper's own curve. Under a moving hue (ramp()'shCycles/hueList) the axes ease independently, as in RampenSau: the hue sequence is unchanged, but a given lightness now pairs with a different hue. - Diverging eases per arm,
t = 0at each dark end and1at the neutral, mirrored. Even-Npalettes lose the uniform step across the join — it becomes whatever the easing leaves between the two innermost samples. fromColortakes the easing as a held knob (see above) so the target keeps its sample.
Use lRange (or brightness/contrast) to shape the range itself.
The API is deliberately kept as close to RampenSau as the
paper allows: if you know one, you know the other. ramp() is the counterpart to RampenSau's
generateColorRamp — everywhere the concepts correspond the options share RampenSau's names and
semantics (total, hStart, hCycles, hStartCenter, hEasing, sRange/sEasing, lRange,
lEasing, hueList), and RampenSau's easing/curve helpers plug straight into hEasing/sEasing. Only the
paper-specific knobs (saturation, brightness, contrast, coolWarm) have no RampenSau
counterpart. One of them changes meaning inside ramp(): under a shared triangleMode there is no
per-hue triangle to shift, so coolWarm instead nudges the light colors' hues toward the bright
point — same visual intent, different mechanism. lEasing differs too: RampenSau's eases lightness
itself, this one eases the position along the paper's lightness curve (see above). A few RampenSau
options are omitted deliberately:
transformFn— colors are plain objects;.map()the result.- Random defaults —
totalandhStartare required. The point of the model is an exact, reproducible specification, so nothing is randomized for you.
The paper's bright point p_b (the yellow that coolWarm pulls toward) is canonically sRGB
yellow in every gamut, so sRGB and Display-P3 palettes stay comparable. It is the same color in
every space, expressed in that space's coordinates (e.g. OKLCH 0.968 0.211 109.8°, LCHuv
97.6 84.7 84.6°).
npm run dev # demo
npm test # unit tests
npm run build:lib # build the libraryMIT © David Aerne