Tailwind v4's default colors and shadcn/ui's theme variables are written in oklch(). If your brand palette lives in hex, at some point you convert it. Online converters work, but I wanted to see the math and check the numbers against a palette I know well, so here's a small converter and what the output taught me.
The palette is from Northwind Roasters, a fictional coffee shop site I use for demos: cream background, terracotta primary, sage green accent, dark brown text.
The converter
OKLCH is OKLab in polar form, and OKLab comes from Björn Ottosson's published matrices. From hex: undo the sRGB gamma, go to an LMS-like space, take cube roots, go to Lab, then turn a/b into chroma and hue.
const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); function hexToOklch(hex) { const [r, g, b] = [1, 3, 5].map((i) => lin(parseInt(hex.slice(i, i + 2), 16) / 255)); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; const C = Math.hypot(A, B); const H = C < 0.0005 ? 0 : (Math.atan2(B, A) * 180 / Math.PI + 360) % 360; return `oklch(${(L * 100).toFixed(1)}% ${C.toFixed(3)} ${H.toFixed(1)})`; } The C < 0.0005 check is for greys and white: with no chroma the hue is meaningless, and atan2 would return noise.
Output for the palette:
| Role | Hex | OKLCH |
|---|---|---|
| background | #fffaf3 | oklch(98.7% 0.011 76.6) |
| surface | #f6eadb | oklch(94.2% 0.024 73.6) |
| border | #ead8c3 | oklch(89.1% 0.035 71.6) |
| muted text | #7a6656 | oklch(52.5% 0.035 60.6) |
| primary | #c2410c | oklch(55.3% 0.174 38.4) |
| accent | #4d7c5a | oklch(54.3% 0.074 152.1) |
| text | #2b1d14 | oklch(24.5% 0.027 53.8) |
oklch(55.3% ...) and oklch(0.553 ...) are the same color; CSS accepts lightness either way.
You can read the palette from this table in a way you can't from hex: all the neutrals sit around hue 54 to 77 (warm), their chroma stays under 0.04 (barely tinted), and they step down in lightness from 98.7 to 24.5. The primary has five times the chroma of any neutral and more than twice that of the green accent.
1. Lightness is not contrast
Primary and accent are almost the same OKLCH lightness, 55.3% and 54.3%. So I expected the accent, being slightly darker, to have slightly more contrast on the cream background. It's the other way round:
| Color | OKLCH L | WCAG contrast on #fffaf3 |
|---|---|---|
primary #c2410c | 55.3% | 4.99:1 |
accent #4d7c5a | 54.3% | 4.65:1 |
muted #7a6656 | 52.5% | 5.24:1 |
WCAG 2 contrast is built on relative luminance, a weighted sum of the linear sRGB channels in which green counts for about 72%. OKLCH lightness is designed to match how light a color looks, which is a different model. They usually agree in direction, but between two colors a point apart they can disagree, as here. OKLCH is great for building scales; still run the actual contrast check on the pairs you ship.
2. Lowering L alone falls out of gamut
The obvious way to make a hover shade in OKLCH is to keep chroma and hue and lower the lightness. Converting back to sRGB shows what happens with a saturated color:
oklch(55.3% 0.174 38.4) -> #c2410b, in sRGB oklch(50% 0.174 38.4) -> blue channel -0.076, outside sRGB oklch(47% 0.174 38.4) -> blue channel -0.119, outside sRGB A negative channel means there's no sRGB color with that lightness, chroma and hue. CSS lets you write it anyway, and the browser has to map it to something the screen can show, so you don't get exactly what you wrote, and a wide-gamut screen may show something different from an sRGB one.
The site's real hover color is #9a3412, which is oklch(47.0% 0.143 37.3): the designer dropped the chroma from 0.174 to 0.143 while darkening. That's the pattern for saturated colors: lower L and C together. A quick way to check is to convert back and look for channels below 0 or above 1.
It goes the other direction too. At the same lightness and hue, the primary's chroma can only go up to about 0.179 before the blue channel goes negative, so 0.174 is already close to the most saturated terracotta sRGB has at that lightness.
3. Rounding costs one step, and that's fine
With three decimals, converting the primary back gives #c2410b, not #c2410c: the blue channel is off by one out of 255. That's invisible, but it means a round trip through the rounded oklch() string won't give you byte-identical hex. If a test compares the colors exactly, compare before rounding or allow a difference of one.
Using it for shadcn/ui
With the values converted, the shadcn/ui mapping is: background to --background and --popover, surface to --card, --secondary and --muted, text to --foreground and the -foreground variables, primary to --primary and --ring, border to --border and --input. White on the primary is 5.18:1, so --primary-foreground can stay white.
One thing to avoid: shadcn's --accent is the background of a hovered menu item, not a brand color. Put the sage green there and every hover turns green. I keep it as a separate --brand-accent and set --accent to a faint tint of the text color.
Short version
- The conversion is 15 lines of math; check it against a known value before trusting it.
- Read the OKLCH table for structure: hue family, how tinted the neutrals are, the lightness steps.
- Don't use OKLCH lightness as a contrast check. Compute WCAG contrast.
- Darken saturated colors by lowering L and C together, then convert back to make sure you're still in sRGB.
I made a Chrome extension, Stylasso, that reads a site's palette with roles and writes this kind of shadcn/ui theme in oklch(); its values for this demo site match the converter above to the last digit. There's a guide here: turn any website into a shadcn/ui theme. If you already have your hex values, the converter is all you need.
Written with help from an AI assistant. Every value above comes from running the code shown on the demo palette.