Tailwind CSS OKLCH Colors: Perceptually Uniform Palettes in v4
Tailwind v4 ships OKLCH colors by default — here's how perceptually uniform palettes work, why they matter, and how to build your own custom scale.
Why Tailwind Switched to OKLCH in v4
Tailwind v4 landed in early 2025 and shipped a quiet but significant change: the entire default palette was rewritten in OKLCH. No more rgb(59, 130, 246) for blue-500. It's now oklch(0.623 0.214 259.1). That switch isn't cosmetic — it fundamentally changes how color scales behave when you use them.
The old RGB-based palette had a dirty secret. When you stepped from blue-400 to blue-500, the perceived brightness change wasn't consistent. Some steps felt like a big jump; others barely registered. That's because RGB is a hardware color model, not a perceptual one. Your monitor can reproduce those values, but your eye doesn't weight them equally.
OKLCH stands for Oklab Lightness Chroma Hue — it's a CSS Color Level 4 color space designed around how human vision actually works. Lightness in OKLCH is perceptually uniform, which means a jump from L=0.5 to L=0.6 looks the same size as a jump from L=0.7 to L=0.8. That property makes programmatic palette generation way more predictable. Worth noting: this isn't a Tailwind invention — OKLCH was spec'd by Björn Ottosson in 2020 and landed in browsers by 2023.
In practice, the switch means your text-red-500 and text-blue-500 now sit at similar perceived brightness levels. That's huge for contrast ratios. It also means you can generate custom brand colors with a simple lightness sweep and they'll actually look like a real, professional scale instead of a random bag of swatches.
How OKLCH Coordinates Actually Work
The syntax is oklch(L C H / alpha). Three numbers, optionally a fourth for transparency. L is lightness from 0 (black) to 1 (white). C is chroma — saturation, basically — which starts at 0 (gray) and goes up to roughly 0.4 for the most saturated colors in sRGB. H is the hue angle in degrees, 0–360.
Here's the part most tutorials skip: chroma range isn't fixed. A high-chroma yellow at H=90 might clip into sRGB at C=0.18, while a blue at H=260 can push past C=0.32 without clipping. The P3 display gamut goes further still. That's why Tailwind's palette doesn't use the same C value across all hues — the actual values vary per hue to stay within sRGB by default.
/* These three all have the same perceived lightness */
.text-red-500 { color: oklch(0.637 0.237 25.3); }
.text-green-500 { color: oklch(0.723 0.197 142.5); }
.text-blue-500 { color: oklch(0.623 0.214 259.1); }Compare that to the old Tailwind v3 values where green-500 had a lightness of ~0.53 in Lab space while red-500 sat at ~0.48. Those 5 points of difference were enough to make red feel darker in practice, which is why you'd often need font-semibold on red text to get visual parity with green at the same size. OKLCH fixes that at the color definition level.
One more thing — you can use OKLCH right now in plain CSS without Tailwind at all. All major browsers have supported it since 2023. But Tailwind v4 is the first major utility framework to make it the default, which means you get the perceptual benefits for free when you upgrade.
Building a Custom Brand Palette with OKLCH
The old workflow was: pick a hex, generate a 50–950 scale with some online tool, copy the values into tailwind.config.js. The results were usually fine but rarely great — especially at the light and dark ends where things either washed out or went muddy.
With OKLCH you can build a scale mathematically. Fix your hue and chroma, sweep lightness from 0.95 down to 0.15, and you get a perceptually even scale every time. Here's what that looks like in tailwind.config.ts with v4's CSS-first config approach:
/* app.css — v4 uses @theme instead of tailwind.config.js */
@import "tailwindcss";
@theme {
--color-brand-50: oklch(0.97 0.02 270);
--color-brand-100: oklch(0.93 0.04 270);
--color-brand-200: oklch(0.87 0.07 270);
--color-brand-300: oklch(0.78 0.11 270);
--color-brand-400: oklch(0.68 0.16 270);
--color-brand-500: oklch(0.58 0.20 270);
--color-brand-600: oklch(0.50 0.20 270);
--color-brand-700: oklch(0.42 0.18 270);
--color-brand-800: oklch(0.34 0.14 270);
--color-brand-900: oklch(0.26 0.10 270);
--color-brand-950: oklch(0.18 0.06 270);
}Notice the chroma doesn't stay constant — it ramps up toward the middle and backs off at the extremes. That's intentional. Very light and very dark colors can't hold high chroma without looking neon or clipping into imaginary colors. The sweet spot for most brand colors is around the 400–600 range where you have room to push C to 0.18–0.22.
Honestly, once you build one palette this way you'll never go back to copying hex codes from Figma. The math just works. And when your designer changes the brand from purple to teal, you change one number — the hue — and the whole scale regenerates correctly.
P3 Wide Gamut: Getting More Saturated Colors
Here's a question worth sitting with: if OKLCH can describe colors beyond sRGB, what happens when you use them in a browser on an sRGB monitor? The browser clamps the value to the nearest in-gamut color. No crash, no error — just silent fallback. On P3 displays (every iPhone since 2016, most Mac displays since 2015, and a growing chunk of PC monitors in 2026), you get the full vivid version.
Tailwind v4 keeps its default palette in sRGB to be safe. But you can use a media query to serve wider gamut colors to capable displays:
@theme {
/* sRGB-safe default */
--color-brand-500: oklch(0.58 0.20 270);
}
@media (color-gamut: p3) {
@theme {
/* Push chroma higher for P3 displays */
--color-brand-500: oklch(0.58 0.28 270);
}
}That extra 0.08 chroma is actually a visible difference on P3 screens — punchy purple vs muted purple. And because you're still using OKLCH with the same L and H, the color reads as the same hue at the same brightness. You're just getting more saturation where the display supports it.
Worth noting: this pairs really well with glassmorphism styles and gradient-heavy interfaces. When you're building something like the cards in Empire UI's glassmorphism components, the gradient underneath the blur has a lot more pop on P3 if you push the chroma. The frosted-glass effect really comes alive with vivid source colors behind it.
Using the New Color System in Practice
Tailwind v4 exposes all palette colors as CSS custom properties automatically, which means you can reference them in arbitrary CSS or in Tailwind's new theme() equivalent — var(--color-blue-500). That's a shift from v3 where theme() was a build-time function. Now it's runtime CSS variables. Actual composability.
// This just works in v4 — no config needed
const GradientCard = () => (
<div
className="rounded-2xl p-6"
style={{
background: `linear-gradient(
135deg,
var(--color-violet-500),
var(--color-blue-500)
)`
}}
>
<p className="text-white font-semibold">Card content</p>
</div>
);If you're generating gradients, check out the gradient generator — it's built around the same OKLCH color space and produces smoother gradients than old-school HSL because the hue sweep stays perceptually consistent. You'll notice especially on orange-to-blue gradients: OKLCH avoids the muddy gray in the middle that HSL always produces.
For shadow work, the box shadow generator now lets you pull shadows from your OKLCH palette. Colored shadows (a blue shadow under a blue card rather than a generic gray one) look dramatically better when the shadow hue matches the element hue — and OKLCH makes it trivial to take a color and darken just the L channel without touching H or C.
Look, the migration from v3 to v4 has a few rough edges around plugin APIs and the config format, but the color system is not one of them. OKLCH palettes just work better from day one. The 50–950 scale feels more consistent, contrast ratios are more predictable, and you spend less time eyeballing whether that button shade works against a dark background.
Color Contrast and Accessibility with OKLCH
WCAG 2.1 contrast ratios are calculated in sRGB luminance — not OKLCH lightness. The two are correlated but not identical, so you still need to check contrast ratios the old-fashioned way. Don't assume L=0.5 in OKLCH is a safe midpoint for text on white. In practice, you need L around 0.4 or lower for 4.5:1 contrast on white backgrounds.
That said, OKLCH makes the *process* more predictable. In the old HSL world, a blue at hsl(220, 80%, 50%) and a yellow at hsl(50, 80%, 50%) have wildly different luminances despite the same L value. With OKLCH, colors at the same L value are genuinely similar in perceived brightness, so you're working with better intuition even if the final WCAG check still needs a tool.
// Quick check — plug these into your contrast checker
// Text on white (#fff)
oklch(0.40 0.18 270) // blue — passes 4.5:1
oklch(0.35 0.16 25) // red — passes 4.5:1
oklch(0.43 0.14 145) // green — borderline, check this oneThe bigger accessibility win is for color-blind users. Because OKLCH separates chroma from lightness cleanly, you can build a palette that works for deuteranopia by ensuring your L values differ significantly between colors you want to distinguish — without needing to muddy the hues. That was always theoretically possible with HSL but practically hard because HSL's L isn't perceptually uniform.
FAQ
Yes — Chrome 111+, Safari 15.4+, and Firefox 113+ all support OKLCH natively. If you need IE11 or very old mobile browsers, you'll need a PostCSS fallback, but that's a pretty niche requirement in 2026.
Absolutely. Tailwind v4 doesn't ban hex or rgb values — you can mix color formats freely. The default palette is in OKLCH but nothing stops you from writing bg-[#ff4d4d] or defining --color-brand: #6366f1 in your @theme block.
Use oklch.com or the CSS Color Converter tool to convert your hex values to OKLCH. Then rebuild the scale by locking the H and C from your brand color and sweeping L from 0.95 to 0.18 — you'll get a much more even result than just converting the existing steps one-for-one.
Yes, noticeably so. HSL gradients through complementary hues produce a muddy gray in the middle — OKLCH interpolation stays vivid because lightness and chroma are held stable across the hue sweep. The gradient generator at Empire UI uses OKLCH interpolation by default.