css color oklch design systems

HEX, RGB, HSL, OKLCH — Which Color Format to Use in Modern CSS

What each CSS color format is good for, why OKLCH is replacing HSL in design systems, what 'out of gamut' means, and how to convert without silently changing the color.

· Mohammed Aquib Ansari

For years my rule for CSS colors was simple: hex for everything, rgba() when I needed transparency, HSL when I wanted to tweak lightness by hand. Then Tailwind CSS v4 shipped its whole palette in oklch(), a designer started handing me colors from a wide-gamut Figma file, and my rule stopped working. This is the mental model I use now, with real numbers from the ByteKiln color converter.

One color, ten spellings

Take Tailwind v3’s blue-500, #3B82F6. The converter writes it in every format at once:

HEX    #3B82F6
RGB    rgb(59 130 246)
HSL    hsl(217.2 91.2% 59.8%)
HWB    hwb(217.2 23.1% 3.5%)
OKLCH  oklch(0.623 0.188 259.81)
OKLab  oklab(0.623 -0.033 -0.185)
Lab    lab(54.62% 8.76 -65.79)
LCH    lch(54.62% 66.37 277.59)

They’re all the same color. What differs is what each notation makes easy.

  • HEX and RGB describe how much red, green and blue light an sRGB screen emits. Great for copying values around, useless for reasoning about “a bit lighter”.
  • HSL and HWB rearrange the same RGB cube into hue, saturation and lightness. Easier to tweak, but the “lightness” isn’t what your eyes perceive.
  • Lab and LCH (CIE, D50 white point in CSS) and OKLab and OKLCH are perceptual spaces: equal numeric steps look roughly equal.

Why HSL lightness lies

Here’s the experiment that convinced me. Pure yellow and pure blue in HSL:

hsl(60 100% 50%)   → oklch(0.968 0.211 109.77)
hsl(240 100% 50%)  → oklch(0.452 0.313 264.05)

Both have HSL lightness 50%. In OKLCH, the yellow has perceived lightness 0.968 and the blue 0.452. That’s why a palette built by stepping HSL lightness has muddy yellows and neon blues, and why two buttons with “the same lightness” can have completely different text contrast.

OKLCH’s L tracks perceived lightness, C is chroma (colorfulness) and H is hue in degrees. If I want a hover state that’s visibly 5% darker, I subtract 0.05 from L and it looks like 5% darker regardless of hue. That’s the reason Tailwind v4 and most new design systems define their scales in OKLCH.

Out of gamut is a real thing now

sRGB can’t display every color. OKLCH and Lab can describe colors beyond it — the extra-saturated greens and reds that P3 displays on recent phones and laptops can show. Try oklch(0.7 0.3 150), a very saturated green:

OKLCH (as entered)   oklch(0.7 0.3 150)
HEX (gamut-mapped)   #00C248
HEX (clipped)        #00CB00

Neither hex value is the color you asked for, because there isn’t an sRGB value that is. The difference is how they got there:

  • Clipping clamps each RGB channel to 0–255. It’s what many converters do silently, and it can shift the hue: here it produced a flat, slightly yellowish green.
  • Gamut mapping, the method in the CSS Color 4 spec, keeps lightness and hue and reduces chroma in OKLCH until clipping would change the color by less than a just-noticeable difference. The result looks like a less saturated version of the same green.

The converter shows both, and uses the gamut-mapped value for HEX, RGB, HSL and HWB. My rule: if a design token is out of gamut, keep the oklch() value in CSS — browsers on wide-gamut screens will show it and gamut-map it themselves elsewhere — and use the mapped hex only where you need an sRGB fallback.

Grays have no hue

Enter #808080 and OKLCH comes back as oklch(0.6 0 none). none is the CSS Color 4 keyword for a missing component: a gray genuinely has no hue. I use it on purpose, because a gray written as oklch(0.6 0 264) will interpolate through blue in a gradient or color-mix(), while none takes the other color’s hue. HSL output writes 0 instead of none because older tools choke on it.

Transparency

Alpha survives every format: #80808080 becomes rgb(128 128 128 / 0.502) and oklch(0.6 0 none / 0.502). The eighth hex digit pair is alpha out of 255, so 80 is 128/255 ≈ 0.502 — not 0.5, which surprises people.

What I actually use

  • Design tokens and Tailwind v4 themes: OKLCH. The converter writes a ready-made line such as --color-brand: oklch(0.623 0.188 259.81);.
  • One-off colors in components: whatever the token system uses; otherwise hex is fine.
  • Anything computed — hover states, disabled states, color-mix: OKLCH, because the math behaves.
  • Legacy stylesheets and email: hex or rgb() with commas, which every client understands.

Converting doesn’t make a color accessible, though. Once you’ve picked a text and background pair, run it through the contrast checker — #3B82F6 on white is only 3.68:1, which fails WCAG AA for body text. And if you’re starting from an image or a brand sheet, Color Palette extracts the colors first.

How the converter stays consistent

Every ByteKiln color tool converts through one shared module built on the culori library, using the CSS Color 4 definitions. The test suite checks its OKLCH, OKLab, Lab and LCH values against the independent Python coloraide library for 50 random colors, to within 0.001. That matters more than it sounds: I’ve seen design tools and converters disagree in the third decimal of OKLCH, which is enough to produce two different hex codes for “the same” token.

The color converter accepts hex with or without # (or with 0x), any CSS color function in comma or space syntax, and all 148 named colors. When something doesn’t parse it tells you which part — five hex digits, a stray g, or rbg() instead of rgb() — instead of just saying “invalid”.