OKLCH Color Ramp: Build a Perceptually Uniform Scale (2026)
Build an OKLCH color ramp where every step looks evenly spaced. A practical 2026 guide to lightness stepping, chroma tapering, and gamut safety.
OKLCH Color Ramp: Build a Perceptually Uniform Scale
Pick a brand color, then make nine shades of it in HSL and the middle ones jump while the extremes barely move. The scale looks lumpy because HSL lightness is not tied to what your eye sees. An OKLCH color ramp fixes this: because OKLCH lightness is calibrated to human perception, stepping it evenly produces a scale that actually looks evenly spaced. This guide shows how to build one, keep it inside the display gamut, and generate it without uploading anything.
TL;DR
- OKLCH lightness is perceptual, so even L steps look evenly spaced.
- Fix hue, step lightness in equal intervals, taper chroma at the ends.
- High chroma at very light or dark L falls outside sRGB and gets clipped.
- Nine to eleven steps (a 50–950 scale) covers almost every UI need.
- Generate and preview the whole ramp in the browser, no server upload.
What a perceptually uniform color ramp is
A color ramp is a set of tints and shades built from a single hue: the light greys you use for backgrounds, the mid-tones for borders, the dark ones for text. A perceptually uniform ramp is one where the visual gap between step 3 and step 4 looks the same as the gap between step 7 and step 8. That evenness is what makes a UI feel deliberate instead of accidental.
Why do my HSL color shades look uneven
HSL defines lightness relative to the color signal, not relative to perception. Fully saturated yellow at hsl(60 100% 50%) looks far brighter than blue at hsl(240 100% 50%), even though both claim 50% lightness. Björn Ottosson demonstrated exactly this when he introduced Oklab in December 2020: a constant-lightness HSV hue sweep shows yellow, magenta, and cyan reading much lighter than red and blue. Step lightness evenly in HSL and your eye sees the steps bunch up in the bright half and stretch out in the dark half.
The three axes: lightness, chroma, hue
OKLCH is the cylindrical form of the Oklab color space, exposing three channels: L for perceived lightness (0 to 1, or 0% to 100%), C for chroma or "amount of color" (0 upward, rarely past about 0.4 in practice), and H for hue angle (0 to 360 degrees). Per the MDN reference, the L in oklch() is the perceived lightness we see with our eyes, which is a different quantity from the L in hsl(). That single distinction is why OKLCH is the right tool for ramps.
Where OKLCH came from
Ottosson built Oklab to solve the operations software actually performs on color: darkening, saturating, and generating palettes. He optimized it against perceptual datasets so that lightness, chroma, and hue behave as independent axes. It has since become the interpolation default for gradients in Photoshop and shipped in CSS as part of Color Module Level 4. When you move only the L value, hue and chroma hold steady, which is precisely what a clean ramp needs.
How to build a color ramp in OKLCH
The recipe is three moves: lock the hue, step the lightness, and manage the chroma. You can do this by hand in CSS custom properties or generate it programmatically.
Step 1: fix the hue, step the lightness
Choose your hue angle once and reuse it for every step. Then spread lightness across the range you need. For a nine-step scale, evenly spaced L values from roughly 0.97 down to 0.30 give you a light-to-dark run:
:root {
--indigo-hue: 264;
--indigo-50: oklch(0.97 0.02 var(--indigo-hue));
--indigo-100: oklch(0.93 0.04 var(--indigo-hue));
--indigo-300: oklch(0.80 0.11 var(--indigo-hue));
--indigo-500: oklch(0.62 0.19 var(--indigo-hue));
--indigo-700: oklch(0.48 0.16 var(--indigo-hue));
--indigo-900: oklch(0.34 0.10 var(--indigo-hue));
}
Because the L values march down in even steps, the perceived jumps between shades stay consistent. Notice the hue is a shared variable, so retheming the whole ramp is a one-line change.
Step 2: taper the chroma at the ends
Constant chroma across the whole ramp looks wrong at the extremes: a near-white with full chroma turns pastel-dirty, and a near-black with full chroma muddies. Push chroma toward zero at both ends and let it peak in the middle, where the color has room to be saturated. This bell-shaped chroma curve is the single biggest quality lever after even lightness.
A 10-step example scale
Here is a 10-step indigo ramp at hue 264. Lightness descends in even increments; chroma rises to a mid-scale peak then falls:
| Step | Lightness (L) | Chroma (C) | Typical use |
|---|---|---|---|
| 50 | 0.97 | 0.02 | Page background |
| 100 | 0.93 | 0.04 | Subtle fill |
| 200 | 0.87 | 0.07 | Hover fill |
| 300 | 0.80 | 0.11 | Border |
| 400 | 0.71 | 0.15 | Disabled text |
| 500 | 0.62 | 0.19 | Primary accent |
| 600 | 0.55 | 0.19 | Accent hover |
| 700 | 0.48 | 0.16 | Body link |
| 800 | 0.41 | 0.13 | Heading |
| 900 | 0.34 | 0.10 | High-contrast text |
You can paste any of these values into the iKit Color Picker & Converter to see the swatch, grab a hex fallback, and confirm the shade reads the way you expect before committing it to a token file.
Why do my OKLCH colors look washed out or clipped
OKLCH can name colors that no screen can show. When you request more chroma than the display supports, the browser has to pull the color back into range, and if it does that badly the result looks flat or shifts hue.
How browsers gamut-map out-of-range chroma
The CSS Color 4 specification defines a gamut-mapping algorithm that works in OKLCH: it runs a binary search on chroma, holding lightness and hue fixed, to find the most saturated value that still fits the target gamut. That is far better than naive per-channel clipping, which pulls a bright saturated color down to something much darker and off-hue. The key insight is that reducing chroma preserves the two things your ramp depends on — lightness and hue.
Keeping a ramp inside sRGB and P3
High-chroma values fail most often at the light and dark ends of the scale, which is another reason the tapered chroma curve matters. A few practical rules keep a ramp safe:
- Keep chroma below about 0.16 when L is above 0.90 or below 0.35.
- Let chroma peak around L 0.55 to 0.65, where the gamut is widest.
- If you target Display-P3, you can push mid-scale chroma higher, but always test the sRGB fallback.
- When a swatch looks duller than its neighbours, it is usually being clipped, not miscalculated.
color-mix() vs hand-tuned stops
You can interpolate between two anchor colors instead of specifying every stop. color-mix(in oklch, ...) blends in the perceptual space, so a mix of white and your accent passes through even, believable intermediates rather than the muddy purple you get mixing in sRGB:
.tint-20 {
background: color-mix(in oklch, var(--indigo-500) 20%, white);
}
.shade-20 {
background: color-mix(in oklch, var(--indigo-500) 80%, black);
}
color-mix() is fast to write, but hand-tuned stops give you full control over the chroma curve. Many teams use color-mix() for a first draft, then adjust the extreme steps by hand.
How many steps should a color ramp have
Nine to eleven steps is the sweet spot for a UI palette. The Tailwind-style 50–950 scale is the de facto standard because it maps cleanly to real roles: 50–200 for surfaces, 300–400 for borders and muted text, 500–600 for accents, 700–950 for text and emphasis.
Generating the scale without a server
You do not need a cloud tool to compute a ramp. A short loop produces the L values and hands the rest to CSS:
const hue = 264;
const steps = 9;
const ramp = Array.from({ length: steps }, (_, i) => {
const L = 0.97 - (i * 0.67) / (steps - 1);
const C = 0.19 * Math.sin((i / (steps - 1)) * Math.PI);
return `oklch(${L.toFixed(2)} ${C.toFixed(2)} ${hue})`;
});
console.log(ramp.join("\n"));
The sin() term gives you the bell-shaped chroma curve for free. Everything runs locally, which matters when the color is an unreleased brand asset. If you would rather not write code, the iKit Color Palette Generator builds and previews a full scale in the browser, and the Gradient Generator uses the same OKLCH interpolation for smooth backgrounds.
Checking contrast at each step
An even-looking ramp is not automatically an accessible one. Before you ship, verify that text steps meet WCAG contrast against their intended backgrounds. Because OKLCH lightness is perceptual, two steps with a large L gap will usually pass, but the middle of the scale is where pairs sneak under the 4.5:1 threshold. Check the exact pairs you actually use rather than assuming.
Reusing one ramp across themes
Once the L and C curves are dialled in, you can spin up a second hue by changing a single number, and the new ramp inherits the same perceptual spacing. That is the real payoff of building in OKLCH: the structure is portable across every color in your system, so a five-hue palette is five hue angles sharing one lightness-and-chroma recipe.
References
- A perceptual color space for image processing — Björn Ottosson's original 2020 Oklab post; used for the HSV lightness-drift example and the L/C/h polar definition.
- oklch() CSS function — MDN Web Docs — cited for perceived-lightness vs HSL lightness and the practical chroma range.
- CSS Color Module Level 4 — W3C spec; source for the OKLCH gamut-mapping (binary-search-on-chroma) behaviour.
- OKLCH in CSS: why we moved from RGB and HSL — Evil Martians; background on why perceptual scales beat HSL for palettes.
Related on iKit
- Why HSL makes lopsided color scales and OKLCH doesn't — the deeper comparison behind why we step lightness in OKLCH instead of HSL.
- A practical OKLCH-in-CSS guide — syntax, browser support, and fallbacks for using oklch() in production.
- How WCAG AA and AAA contrast ratios are calculated — the math to check every text step in your ramp passes.
- HEX to RGB: how the conversion works — for exporting ramp steps to the hex fallbacks older tools still expect.
- RGB to HEX: the designer-and-developer guide — the reverse trip, handy when a tool hands you rgb() and you need a token.
Related posts
How to Count Words in a Translation Pair Correctly (2026)
The source and target of a translation pair almost never have the same word count. Here's how to count both sides correctly, including CJK, in 2026.
How to Hit a 1,000-Word Target Without Padding (2026)
Need to hit a 1,000-word target without padding? Plan the structure, budget each section, and reach the count with writing that earns every word.
Reading Time vs Speaking Time: Sizing a Talk (2026)
Reading time and speaking time are not the same number. Here is how to estimate how long a talk runs from a script, using real 2026 words-per-minute rates.