OKLCH Color Space Explained — Why Tailwind v4 Adopted It
Open the source of any 2025-era design system — shadcn/ui, Radix Themes, the Tailwind v4 base palette — and the first thing that jumps out is the colors. Not hex codes, not hsl() triplets, but a function nobody talked about three years ago: oklch(). Tailwind v4 ships its entire default palette as OKLCH literals. shadcn now generates themes that emit OKLCH custom properties. Vercel’s design system rebuilt around it in 2024.
This is not bandwagoning. There is a specific, mathematical reason every serious design system has been quietly switching color models, and once you see it, you cannot unsee why HSL was always wrong for what we used it for.
This post walks through that reason from first principles, ends with a worked conversion from a hex code to OKLCH, and gives you a migration recipe for your own palette.
When color spaces broke
Design systems have a tonal job. A button hovers slightly lighter than its rest state. A muted card sits one notch darker than the surface around it. A focus ring needs to be visibly brighter than the neutral chrome behind it. Doing this well, at scale, requires that “lighter” and “darker” mean the same thing across every hue in your palette.
That requirement was easy to ignore when palettes had eight colors and three states. It became uncomfortable when teams started shipping 11-step ramps (50–950 in Tailwind’s convention), eight semantic colors, light and dark variants, and brand accents that had to coexist with system colors from iOS, Android, and the Web. Suddenly the question of “is this teal-500 the same lightness as our blue-500” was a real engineering problem, not an art-direction luxury.
HSL — the workhorse model since CSS 3 — could not answer it. Two HSL colors with identical L values can look dramatically different in perceived brightness. A pure HSL yellow at lightness: 50% looks far brighter than a pure HSL blue at the same lightness. Your eyes do not perceive yellow and blue equally; HSL was designed to be intuitive for pickers, not perceptually consistent for ramps. By 2023, every design system that scaled past a handful of colors was patching around this with custom mixing scripts or hand-tuned overrides.
What we needed was a color model where L actually meant “the perceived brightness a human would report,” and where rotating hue or reducing saturation did not invisibly change brightness as a side effect. That model existed in academic color science — it just hadn’t reached CSS yet.
The HSL problem, concretely
Drop these into a browser and look at them side by side:
.a { background: hsl(60 100% 50%); } /* yellow */
.b { background: hsl(240 100% 50%); } /* blue */
.c { background: hsl(120 100% 50%); } /* green */
All three have L: 50%. None of them look like they have the same lightness. The yellow nearly burns; the blue reads almost black against a white page; the green sits between them. If you build a hover state by adding 10% to L, the yellow’s hover is barely visible while the blue’s hover is a dramatic shift. Your interaction polish ends up depending on which hue the designer happened to start from.
This is not a bug in HSL. HSL was designed in 1978 for paint-by-numbers color pickers, where users would manipulate hue, saturation, and “lightness” — defined as (max(R,G,B) + min(R,G,B)) / 2 — to dial in a color. The math has no notion of human perception. Lightness in HSL is a geometric midpoint of sRGB channels, nothing more.
The CIE — the international standards body for colorimetry — knew about this problem since the 1970s. They published two perceptually-uniform spaces, CIELAB and CIELUV, that defined lightness as something closer to what human vision actually does. By the 1990s, CIE LAB was the standard in print, photography, and color management. But its conversion to RGB is gnarly, and CSS never adopted it widely. Web developers continued using HSL not because it was right but because it was there.
CIE LAB / LCH: the academic fix, with its own problems
CIELAB takes an XYZ tristimulus value (a model of how human cones respond to light) and runs it through a cube-root and a 2D rotation to produce three channels: L* (lightness, 0–100), a* (green ↔ red), and b* (blue ↔ yellow). LCH is the same space expressed in polar form: L*, C* (chroma, distance from neutral), H* (hue angle).
These spaces are perceptually uniform in a measurable sense. A ΔE of 1 — a unit step in any direction in LAB space — is approximately the smallest color difference a trained observer can detect. Print and prepress workflows have run on LAB and LCH for decades.
So why didn’t CSS just adopt LCH and move on?
Two reasons. First, CIE LAB was calibrated against a specific viewing condition (a 2° standard observer under D50 illumination) optimized for surface reflectance, not emissive displays. On screens, its perceptual uniformity drifts — colors that are “equally bright” in LAB don’t always look equally bright on a phone. Second, the LCH gamut is awkward. There are visible colors that LAB describes well but that sit outside common display gamuts, and the mapping from LCH to sRGB occasionally produces hue shifts (your blue purples slightly when you reduce its chroma). For design-system work, both are deal-breakers.
CSS Color 4 did add lab() and lch() in 2021, and they do work in modern browsers. But for the specific problem of building consistent tonal ramps on emissive screens, the community kept looking.
OKLAB / OKLCH: Ottosson’s 2020 insight
In December 2020, Björn Ottosson — a Swedish color engineer — published a paper titled “A perceptual color space for image processing.” The paper was small: three short matrices, a cube-root step, no calibration tables, no copyrighted reference data. Ottosson took the existing IPT and CAM16-UCS color models — academic spaces with good properties but bad math — and derived a simpler space that approximated their perceptual behavior using ordinary matrix multiplications on linear-light XYZ tristimulus values.
He called it OKLAB. The polar form is OKLCH.
What makes OKLCH special is not novelty — it is fit for purpose. Three properties together:
- Lightness in OKLCH is genuinely perceptual. A pure yellow at
L: 0.7and a pure blue atL: 0.7look like the same brightness on a calibrated display. Hover states defined asL + 0.05produce visually equivalent shifts across the entire palette. - Hue is preserved under chroma changes. If you reduce the C value of
oklch(0.7 0.2 30)tooklch(0.7 0.1 30), the hue stays put. In LCH, the same operation often introduces a visible hue shift. In OKLCH, you can flatten chroma to build a muted variant of a brand color without it accidentally drifting toward a different hue. - The math is cheap. Two matrix multiplications and one cube-root. Implementable in a 30-line JavaScript function. No lookup tables, no per-device calibration, no licensing concerns.
The combination is what made OKLCH usable in real CSS. The W3C added oklch() to CSS Color 4 in 2022. Chrome 111 shipped it in 2023. Every evergreen browser supported it by mid-2024. Tailwind v4 made it the default palette format the same year.
The math: a worked HEX → OKLCH conversion
Let’s walk through #3b82f6 — Tailwind’s blue-500 — to OKLCH. This is the same math the Color Converter tool and the HEX to OKLCH spoke run on every keystroke. Knowing what’s happening under the hood will make the gotchas in the next section make more sense.
Step 1: Hex to sRGB. Split the six-digit hex into three pairs and divide each by 255.
const r = 0x3b / 255; // 0.231
const g = 0x82 / 255; // 0.510
const b = 0xf6 / 255; // 0.965
These are gamma-encoded sRGB values: the channel values as your image file stores them, with a non-linear curve baked in to compensate for how monitors emit light.
Step 2: sRGB to linear sRGB. Remove the gamma curve so we have linear-light channel values. The standard piecewise transform from CSS Color 4 §11.2:
const linear = (v) => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
const [lr, lg, lb] = [r, g, b].map(linear);
// lr ≈ 0.045, lg ≈ 0.220, lb ≈ 0.923
Step 3: Linear sRGB to XYZ D65. A standard matrix multiplication, defined in CSS Color 4 §15.1:
const x = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb;
const y = 0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb;
const z = 0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb;
// x ≈ 0.265, y ≈ 0.231, z ≈ 0.927
This is the canonical XYZ tristimulus representation — the “what wavelengths is this color, in terms of human cone response” form.
Step 4: XYZ to LMS. Ottosson’s first matrix maps XYZ to a long/medium/short cone-fundamentals space tuned for OKLAB:
const lms = [
0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z,
0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z,
0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z,
];
Step 5: Cube-root the LMS values. This is the perceptual-compression step — analogous to the cube-root in CIE LAB:
const lms_ = lms.map(Math.cbrt);
Step 6: LMS’ to OKLAB. Ottosson’s second matrix:
const L = 0.2104542553 * lms_[0] + 0.7936177850 * lms_[1] - 0.0040720468 * lms_[2];
const a = 1.9779984951 * lms_[0] - 2.4285922050 * lms_[1] + 0.4505937099 * lms_[2];
const b_ = 0.0259040371 * lms_[0] + 0.7827717662 * lms_[1] - 0.8086757660 * lms_[2];
// L ≈ 0.629, a ≈ -0.022, b_ ≈ -0.191
That’s OKLAB. The lightness channel L ≈ 0.629 is what the eye perceives as “60% brightness” for this particular blue.
Step 7: OKLAB to OKLCH (Cartesian to polar).
const C = Math.sqrt(a * a + b_ * b_); // 0.193
const H = (Math.atan2(b_, a) * 180 / Math.PI + 360) % 360; // 263.4
// → oklch(0.629 0.193 263.4)
So #3b82f6 is oklch(0.629 0.193 263.4). Lightness 0.629, chroma 0.193, hue 263.4°.
If you build a 50–950 ramp from this color by varying only L (0.95 down to 0.15 in 11 steps), holding C and H fixed, you get a palette where every shade is visibly the same hue, fading in lightness uniformly. Do the same in HSL and the dark shades shift purple while the light shades go gray. That is the win.
Display P3 + Rec2020: why OKLCH unlocks wide gamut
OKLCH is unbounded. Unlike HSL (capped to sRGB) and even LCH (calibrated for reflective surfaces), OKLCH has no implicit gamut. You can write oklch(0.7 0.25 30) and produce a vivid red that sits inside Display P3’s color volume but outside sRGB’s. On a recent iPhone or MacBook, it renders. On an older monitor, the browser snaps it to the nearest sRGB representation automatically.
This matters because Apple, Samsung, and the W3C all spent the late 2010s shipping wide-gamut hardware. The MacBook Pro 14” / 16” with mini-LED ships P3 by default. The iPhone 15 Pro renders Display P3 in Safari. Android flagships ship Rec2020 panels. By 2025, a substantial fraction of design-system traffic is on wide-gamut hardware that can show colors HSL/sRGB simply cannot express.
OKLCH lets you write those colors without bolting on a separate @media (color-gamut: p3) declaration. The browser handles the fallback. Your design system gets a “use the brightest red the device can render” out of the box.
This is also why OKLCH is the right format for design tokens. A --brand variable in OKLCH is a device-independent description of intent. The browser figures out what to render on whatever display the user has, and your code is portable across CSS, SwiftUI (which natively supports Display P3 Color), Android Compose (Rec2020-aware), and Flutter.
Tailwind v4 and the design-token revolution
Tailwind v4 — released in 2024 — was the inflection point that turned OKLCH from research into industry default. Tailwind’s authors made three opinionated calls:
- The default palette is OKLCH. Slate, gray, zinc, neutral, stone — every Tailwind color is defined in
oklch()in the source. The 50–950 ramps are uniform in perceptual lightness by construction. - Custom themes use
@themeblocks with OKLCH literals. Brand colors are defined asoklch()tokens; downstream utilities (bg-brand-500,text-brand-300) are generated. - No fallback ceremony required. Browsers without OKLCH support are below Tailwind v4’s documented baseline.
That last decision was the one that made adoption a footgun-free choice. As recently as 2023, designers had to ship both oklch() and hsl() versions of every color so older Safari versions wouldn’t break. With Tailwind v4, the baseline is browsers from 2023 or newer, and OKLCH works everywhere.
shadcn/ui’s theme generator follows the same pattern: enter your brand color, get an OKLCH ramp out. Vercel’s design system uses OKLCH for its semantic colors. Radix Themes’ color scales are defined in OKLCH. The community has converged.
Practical migration: HEX palette → OKLCH palette
If you have a hex-based palette today, the migration is mechanical. Here is the recipe:
1. Decide on your ramp structure. Tailwind’s 50–950 (11 stops) is the de-facto default and worth following unless you have a specific reason otherwise. Stops at L = 0.97, 0.93, 0.86, 0.76, 0.63, 0.50, 0.42, 0.34, 0.26, 0.18, 0.10 give a smooth perceptual ramp.
2. Convert your brand hex to OKLCH. Use the Color Converter or HEX to OKLCH tool. You’ll get a triplet like oklch(0.629 0.193 263.4). Note the H value — this is your brand hue.
3. Hold C and H constant; vary L. Build the ramp by emitting:
--brand-50: oklch(0.97 0.193 263.4);
--brand-100: oklch(0.93 0.193 263.4);
...
--brand-950: oklch(0.10 0.193 263.4);
4. Tune extreme stops. At very low L (≤ 0.20) and very high L (≥ 0.95), high chroma values fall outside sRGB. Reduce C for those stops, or accept the browser’s auto-snap. Tailwind’s defaults reduce chroma toward both ends — copy that pattern.
5. Define semantic aliases. --surface: var(--brand-50), --surface-elevated: var(--brand-100), --text-primary: var(--brand-900), etc. Now your design tokens read as intent, not as colors.
6. Verify contrast. Use APCA Lc or WCAG 2 contrast ratios to confirm each --text-* pair against each --surface-* pair meets your accessibility bar. Because OKLCH L is perceptual, the contrast math is more reliable than it would be in HSL.
A team running this migration on a 60-color legacy palette typically lands a smaller, more uniform OKLCH palette of 30–40 tokens in a single afternoon. The new palette ships smaller, generates smaller CSS, and produces visibly better tonal motion in hover states and disabled states without any extra tuning.
Pitfalls and how to handle them
A few things to know going in:
Out-of-gamut warnings. Some OKLCH values fall outside Display P3 or sRGB. Modern browsers handle the snap to the nearest valid color automatically, but the snap is lossy: your oklch(0.7 0.25 30) may render slightly less saturated than you wrote it. Tools like the Color Converter’s gamut row tell you whether your color is sRGB-safe, P3-safe, or Rec2020-safe, and offer a one-click snap-to-sRGB so what you write is what you see.
Sub-pixel chroma weirdness. OKLCH chroma is unbounded, but the useful range is roughly 0 to ~0.4 for visible colors. Values above 0.4 are achievable only with monochromatic laser light — they aren’t physical colors a display can render. The Color Converter caps the chroma slider at 0.4 for this reason; values beyond it produce no perceptible difference on any real display.
Browser support, 2025-style. Chrome 111+, Safari 15.4+, Firefox 113+ all support oklch() natively. Pre-2023 browsers do not. If you have to support legacy IE/Edge or older mobile Safari (1–3% of traffic depending on your audience), you can pair an OKLCH declaration with a hex fallback using @supports (color: oklch(0 0 0)) — but for design-system tokens shipping in 2025, the cost of the fallback often outweighs the legacy benefit.
Hex code permanence. OKLCH is for design-system intent. Your CMS may still need a hex value for legacy reasons (email signatures, Office documents, brand-asset checklists). Keep a generated lookup table that emits the sRGB-snapped hex for each OKLCH token, but don’t author in hex.
Don’t conflate OKLCH and OKLAB. OKLAB is the rectangular form (L, a, b channels); OKLCH is the same color space in polar form (L, C, H). You convert between them with one cartesian↔polar step. Use OKLCH for tokens (more readable, easier to ramp); use OKLAB internally if you need to interpolate or blend colors.
Try it on your own palette
The fastest way to see what we’ve described here in action is to drop a brand hex into the Color Converter. Type your brand color in the HEX field and read the OKLCH output. Then move the sliders on the OKLCH side and watch how the same hue stays the same hue as you reduce chroma, and how the same lightness stays the same lightness as you rotate hue. After a few minutes you will have an intuitive sense of why HSL was always the wrong tool for tonal ramps, and why every serious design system has moved on.
For the specific HEX-to-OKLCH conversion, use HEX to OKLCH — the same math as this article, with one extra benefit: it shows you the gamut classification (sRGB / Display P3 / Rec2020) so you know which of your brand colors are safe everywhere versus which need a wide-gamut device to render fully.
That’s OKLCH. Worth the migration. Done well, you’ll never write hsl() again.