A palette, in OKLCH
Pick one colour and read a whole ramp off it — the thing OKLCH is for, and the thing no other picker will do for you.
<div class="demo demo--wide">
<div class="demo-split">
<div>
<label class="demo-label" for="seed">Seed colour</label>
<input id="seed" name="seed" value="#8338ec">
</div>
<div class="ramp" id="ramp"></div>
</div>
<p class="demo-hint">Each step keeps the hue and chroma and moves only the lightness, so every shade looks
like the same colour — in HSL it would not. Where sRGB cannot show that much chroma, the step says
how much had to come off instead of clipping it behind your back.</p>
</div>
import { createColorPicker, rgbToOklch, oklchToRgb, inSrgbGamut, format, contrast, rate, parse, readableOn } from 'https://devix.pk/cdn/oss/color-picker@1.0.0/index.js';
const ramp = document.getElementById('ramp');
const steps = [95, 90, 80, 70, 60, 50, 40, 30, 20, 10];
/**
* The chroma a seed carries in the middle is one no screen can show at 95%
* lightness. Everything else clips it and hands you a colour you did not pick;
* here the chroma is brought down until sRGB can honour it, and the step says so.
*/
function fit(wanted) {
if (inSrgbGamut(wanted)) return { oklch: wanted, trimmed: 0 };
let low = 0;
let high = wanted.c;
for (let i = 0; i < 12; i++) {
const mid = (low + high) / 2;
if (inSrgbGamut({ ...wanted, c: mid })) low = mid;
else high = mid;
}
return { oklch: { ...wanted, c: low }, trimmed: Math.round(((wanted.c - low) / wanted.c) * 100) };
}
const picker = createColorPicker(document.getElementById('seed'), {
format: 'oklch',
alpha: false,
onChange: () => build(),
});
function build() {
const seed = rgbToOklch(picker.getRgb());
ramp.textContent = '';
for (const step of steps) {
// Only the lightness moves. That is the whole point of OKLCH.
const { oklch, trimmed } = fit({ ...seed, l: step / 100 });
const rgb = oklchToRgb(oklch);
const hex = format(rgb, 'hex');
const row = document.createElement('div');
row.className = 'ramp-step';
row.style.background = hex;
row.style.color = format(readableOn(rgb), 'hex');
row.innerHTML = `<span>${step}</span><span>${hex}</span><span>${rate(contrast(rgb, parse('#fff'))).toUpperCase().replace('-', ' ')}</span>`;
if (trimmed) {
row.lastElementChild.textContent = `−${trimmed}% chroma`;
row.title = `sRGB cannot show this chroma at ${step}% lightness, so it was brought down instead of clipped.`;
}
ramp.append(row);
}
}
build();
@import url('https://devix.pk/cdn/oss/color-picker@1.0.0/color-picker.min.css');
.demo-split { display: flex; flex-wrap: wrap; gap: 28px; align-items: flex-start; }
.demo-split > div:first-child { flex: 0 0 auto; }
.demo-label { display: block; margin-bottom: 8px; }
.ramp { flex: 1 1 260px; min-width: 260px; overflow: hidden; border-radius: 12px; }
.ramp-step {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 9px 14px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.ramp-step span:first-child { font-weight: 650; }
.ramp-step span:last-child { opacity: 0.75; }