Skip to content
Chapter 21Lesson 2

OKLCH, color-mix(), and the alpha syntax

How to author color in a modern CSS and Tailwind project with OKLCH, color-mix(), and per-property alpha.

Here is a small, real problem. You have a brand color and you want a hover state for your primary button: the same color, eight percent brighter. In 2024 your token looked like this:

--brand: #4f46e5;

Hex renders identically on every screen, which is its one virtue. But “eight percent brighter” is not something it can express. To get the hover color you’d reach for a JavaScript color library, compute the lighter shade at build time, and store it as a second token, then do the same for the active state, the disabled state, and the tinted background. You end up with a whole palette of pre-computed steps, all because hex can’t be nudged.

The 2026 form of that token solves the whole thing:

--brand: oklch(0.62 0.22 263);

And the hover state is one line of CSS, computed live in the browser:

background: color-mix(in oklch, var(--brand), white 8%);

You already live downstream of this machinery. You write bg-card text-card-foreground and let the theme pick the values; you write bg-blue-500/50 for a half-transparent background; you’ve seen oklch(...) in the dark-mode tokens without anyone saying why. This lesson fills in the skipped part, moving you from a consumer of color tokens to an author of them: OKLCH as the space colors are stored in, and color-mix() as the function that derives related colors at runtime.

You have seen the shape: oklch(L C H), three numbers, where 0 0 for the last two channels gives a neutral grey. Here is what each number does.

OKLCH describes a color with three independent dials:

  • L, lightness, from 0 to 1 (also written as a percentage, 0% to 100%). oklch(1 0 0) is pure white; oklch(0 0 0) is pure black. It is perceptually uniform: equal numeric steps look like equal brightness steps, so 0.4 to 0.5 is the same visual jump as 0.7 to 0.8. No other common color space does this.
  • C, chroma, the saturation. 0 is fully grey; around 0.4 is about as vivid as colors get. The spec sets no hard ceiling, but the practical maximum depends on lightness and hue, because very light and very dark colors can’t be as saturated.
  • H, hue, an angle from 0 to 360 degrees around the color wheel, the same wheel HSL uses. Red sits near 30, green near 145, blue near 260.

To build intuition, move each dial on its own and watch what changes. In the playground below, drag one slider at a time. The readout assembles the exact oklch(...) string, the same value you could paste into a token.

Move one channel at a time and watch only that dimension change.

Two reasons make OKLCH the default for tokens. The first you have to see.

In OKLCH, changing one channel does not disturb the others. A blue made 10% lighter stays just as blue and just as saturated; only its brightness moves. This is not true of HSL, the space most developers learn first.

HSL also has three channels, hue, saturation, and lightness, but they are not independent. Raise the lightness of an HSL blue and it quietly desaturates and drifts toward violet, coming out washed-out and almost a different color. This is what made hand-built hover palettes look muddy for a decade: you nudge lightness, the color shifts under you, and you spend an afternoon hand-correcting saturation to compensate.

The comparison below drives two swatches from one lightness slider, OKLCH on the left and HSL on the right, both starting from the same blue. Drag the slider up: the OKLCH swatch climbs in brightness while holding its color, and the HSL swatch slides toward grey and violet.

Same lightness change in two color spaces; watch the HSL swatch drift toward grey and violet.

Once you have seen it, storing colors in OKLCH stops being a preference: it is the obvious choice for any system where you derive variants by moving lightness.

The second reason is reach. A gamut is the set of colors a space can express, and hex and rgb() are both stuck inside sRGB, the gamut of twenty-year-old monitors. Modern displays show a wider gamut called P3 , with visibly more vivid greens and reds. OKLCH can address those colors; sRGB syntax cannot name them.

You do not manage two versions. If an OKLCH color falls outside sRGB and the user is on an older monitor, the browser maps it down to the nearest color that screen can show. Write the OKLCH value you want and let the browser handle the fallback.

Tailwind v4 ships its entire default palette in OKLCH, and shadcn stores its semantic tokens in OKLCH, so you have been shipping OKLCH whether or not you typed it. Hex still turns up in legacy code and copied snippets, and you will read it fine. But in new code, you write OKLCH.

Deriving colors at runtime with color-mix()

Section titled “Deriving colors at runtime with color-mix()”

OKLCH gives you a color space where moving lightness behaves predictably. color-mix() puts that to use: it blends two colors in the browser at runtime, so you can derive a related color instead of storing it.

The canonical form, the one you will write most:

color-mix(in oklch, var(--brand), white 8%)

Read it as “take --brand, blend in 8% white.” Three parts do the work:

  • The interpolation space, in oklch. The color space the browser travels through while blending; the choice changes the result.
  • The two colors, var(--brand) and white.
  • An optional percentage on the second color. 8% makes the result 92% brand, 8% white. Leave it off for an even 50/50 mix.

The interpolation space matters here for the same reason it did for HSL lightness. Mixing in oklch follows a perceptually even path and keeps the result vivid. Mixing the same two colors in srgb cuts a straight line through a non-uniform space and lands on a muddy grey midpoint, the dull result every legacy color library shipped by default.

The most extreme mix shows it clearest: blend pure blue with pure red, 50/50. The swatches below do exactly that, with the interpolation space as the only difference. In OKLCH you get a vivid purple; in sRGB you get a dim, greyed-down one.

in oklch
vivid
in srgb
muddy

The same blue-and-red mix. in oklch keeps the purple vivid; in srgb lands on a grey middle. The interpolation space is the only difference.

Here is what color-mix() buys you in real components.

Hover and active states. Darken on press by mixing toward black, lighten on hover by mixing toward white:

.button:hover {
background: color-mix(in oklch, var(--primary), black 8%);
}

This is the principle from the cascade chapter, in “Custom properties & tokens”: express a state by deriving it, not by minting a new --primary-hover token. There you used opacity. color-mix() extends the move to lightness, so you can darken and lighten on the fly, not just fade.

Tinted surfaces. Pull a few percent of a brand color into a neutral surface for a subtly branded background:

background: color-mix(in oklch, var(--card), var(--primary) 4%);

Four percent reads as “warmer than plain grey” without becoming a colored panel.

Token-driven transparency. Mix toward transparent instead of a color to fade the token to semi-opacity:

border-color: color-mix(in oklch, var(--border), transparent 50%);

That last one bridges to the next section: mixing toward transparent is exactly what Tailwind does internally every time you write a /N opacity modifier.

color-mix() has been Baseline Widely Available since 2023 (Chrome 111, Firefox 113, Safari 16.2). It is a default tool, not a progressive enhancement.

The alpha syntax: bg-blue-500/50 is a color-mix() call

Section titled “The alpha syntax: bg-blue-500/50 is a color-mix() call”

You met the /N modifier in the first Tailwind lesson, “Utility-first on JSX,” as part of the variant grammar: bg-blue-500/50 for a half-opacity background. Here is how it works, and it builds on the color-mix() function you just learned.

Every color utility accepts a /N suffix that sets the alpha (bg-blue-500/50, text-foreground/70, border-border/40), and each one compiles to a color-mix() call. bg-blue-500/50 becomes:

background-color: color-mix(in oklab, var(--color-blue-500) 50%, transparent);

It mixes your color with transparent at the percentage you asked for. Two consequences are worth holding onto.

It composes even when the base is a token. Because the modifier mixes the resolved value of the color with transparent, it works on semantic tokens too: bg-primary/50 mixes whatever --primary currently resolves to with transparent, so the theme swap and the alpha apply together. This is why Tailwind v4 dropped the old limitation that you couldn’t fade a color coming from a CSS variable.

The space here is oklab, not oklch. Tailwind’s alpha mix uses in oklab, while the manual mixer you write for tints uses in oklch. OKLAB is the same color model laid out on straight axes with no separate hue channel. The difference has no effect here: when one side is transparent, there is no second hue to preserve, so oklab and oklch produce the same result. The two diverge only when you blend two real colors.

bg-black/50
background-color: color-mix(in oklab, black 50%, transparent);
/* text-foreground/70 */
color: color-mix(in oklab, var(--foreground) 70%, transparent);
/* bg-primary/50 — base is a token, resolved before the mix */
background-color: color-mix(in oklab, var(--primary) 50%, transparent);

The class you type lives in the comment; the declaration below it is what Tailwind generates. bg-black/50 becomes a color-mix() that blends your color toward transparent.

bg-black/50
background-color: color-mix(in oklab, black 50%, transparent);
/* text-foreground/70 */
color: color-mix(in oklab, var(--foreground) 70%, transparent);
/* bg-primary/50 — base is a token, resolved before the mix */
background-color: color-mix(in oklab, var(--primary) 50%, transparent);

The /50 is the percentage of the color that survives; the rest is transparency. The space is oklab, which doesn’t matter here: with transparent on one side there’s no second hue to preserve.

bg-black/50
background-color: color-mix(in oklab, black 50%, transparent);
/* text-foreground/70 */
color: color-mix(in oklab, var(--foreground) 70%, transparent);
/* bg-primary/50 — base is a token, resolved before the mix */
background-color: color-mix(in oklab, var(--primary) 50%, transparent);

When the base is a token, the mix runs on its resolved value, so the theme swap and the alpha both apply. That’s why v4 dropped the can’t-fade-a-CSS-variable limitation.

1 / 1

Reach for the alpha syntax wherever you want a layer to be see-through but the content on top to stay solid: a dialog backdrop dimming the page (bg-black/50), a glass-morphism header, a translucent hairline border on a dark surface (border-white/10). There is a second way to make things see-through, though, and picking the wrong one is a real bug, which is the decision the next section covers.

opacity vs. alpha: two tools, one decision

Section titled “opacity vs. alpha: two tools, one decision”

Both opacity-50 and bg-black/50 make something see-through, but they fade different things, and picking the wrong one produces a recognizable bug.

opacity-* fades the entire element and every child inside it. The browser renders the element and all its descendants, then fades the finished result as one image, so it composites rendered pixels rather than changing a color. It also creates a stacking context, like transform and filter do.

Use it when the whole control should read as inactive, such as a disabled button or a pending card waiting on a request: the label, the icon, and the border all dim together.

<button disabled className="opacity-50">
Save changes
</button>

Per-property alpha, the /N modifier and the color-mix() form from the last section, fades only the one declaration it is attached to. That is what a dialog backdrop needs: the dimmed layer should be translucent, but text and icons on top of it must stay fully opaque and readable.

The bug appears when you reach for opacity-* here. opacity-50 on the overlay fades the text on top along with the backdrop; bg-black/50 fades only the background and leaves the text crisp.

<div className="fixed inset-0 bg-black opacity-50">
<p className="text-white">Saving your changes…</p>
</div>

The text fades too. opacity-50 composites the whole element, so the backdrop and the message drop to 50% and the text turns muddy.

Saving your changes…
Saving your changes…
opacity-50 text fades
bg-black/50 text crisp

Identical markup. On the left opacity-50 fades the message with the backdrop; on the right bg-black/50 dims only the background and the text stays crisp.

The decision rule fits in one line: to fade the whole thing, reach for opacity-*; to fade one layer and keep the content crisp, reach for /N alpha.

You already know the rule: components reference semantic role tokens, never raw primitives. You write bg-card text-card-foreground border-border, not bg-white text-zinc-900. The component asks for a role, “the card surface, with the on-card text color,” and the theme supplies the value.

What this lesson adds is what those values are. Here is a card token defined for light mode and re-pointed for dark:

app/globals.css
:root {
--card: oklch(1 0 0);
--card-foreground: oklch(0.21 0.006 285);
}
.dark {
--card: oklch(0.21 0.006 285);
--card-foreground: oklch(0.985 0 0);
}

Between the two blocks, only the L channel changes for the surface color. The light card is oklch(1 0 0), white; the dark card is oklch(0.21 ...), the same hue and chroma but much darker. This is the “lower the L, keep the H” move, and now you can see why it works: OKLCH’s perceptual uniformity means dropping lightness produces a believable dark surface without re-tuning saturation or hue. Re-pointing one variable re-themes every bg-card in the app, because you darken its OKLCH value by moving a single number.

When a semantic surface needs a hover state, you have both tools from this lesson. For a one-off, derive it: color-mix(in oklch, var(--card), var(--foreground) 5%) nudges the card toward the text color for a subtle lift. But shadcn ships a dedicated --accent token for exactly this case. When a state recurs across the app, that is the signal to promote it to a token rather than re-derive it everywhere.

Two signals tell your app whether to go dark, and next-themes reconciles them for you.

  • prefers-color-scheme is the OS-level preference: the light/dark setting the user picked for their whole system.
  • The .dark class on <html> is the site-level preference: what the user picked in this app’s theme toggle.

They can disagree, and next-themes resolves the precedence. With defaultTheme="system", the app follows the OS until the user touches the in-app toggle, after which the site preference wins and persists. You never arbitrate this by hand.

On the Tailwind side, the dark: variant compiles to the class-based .dark selector. So the markup below needs no dark:: you write bg-card text-foreground once and the tokens flip underneath in both themes. Reach for dark: only for a genuine one-off, like a shadow that needs a different value in dark mode.

<article className="bg-card text-card-foreground border-border">
{children}
</article>

Two more variants are worth recognizing for accessibility audits: contrast-more: targets users on OS high-contrast mode (prefers-contrast: more), and forced-colors: targets Windows High Contrast Mode (forced-colors: active).

Any color you pick can fail the one test that matters most: can a person actually read the text? WCAG sets the floor, and the numbers are short enough to memorize.

  • Body text needs a 4.5:1 contrast ratio against its background. This is the AA bar for normal text.
  • Large text and UI elements, meaning big headings, icons, and the borders of controls, need 3:1. The eye forgives lower contrast when the shape is bigger.
  • The stricter AAA level asks for 7:1 (body) and 4.5:1 (large); reach for it only when an audit demands it.

Every color-mix() tint and lightness choice can slide below the threshold without you noticing. The -foreground half of each token pair exists to prevent that: card-foreground is tuned to clear 4.5:1 against card. OKLCH makes the check easy, because L is perceptually uniform, so “is this text dark enough against that background” becomes a question about one readable number.

The playground below makes that visible. Drag the text lightness against the fixed background and watch the ratio cross the 4.5:1 line.

Drag the text lightness to find the point where it passes AA.

You rarely tune these by hand. Chrome DevTools’ color picker shows the live ratio and an AA/AAA badge next to the swatch, so open it on any text element to read the verdict. The Tailwind palette shadcn ships is already contrast-audited, one more reason to stay on semantic tokens: the work is done for you.

This is the working slice of accessibility, enough to keep day-to-day color choices honest; the full audit workflow comes later.

Three quick utilities round out the toolbox.

currentColor. The inherit-the-text-color keyword, exposed in Tailwind as text-current, bg-current, and border-current. An icon or border set to currentColor tracks the element’s color: change the text color and they follow, with no second token. Use it for icons that recolor with their label and for hairline borders that match the text for free.

Arbitrary color values. When a one-off can’t be expressed with a token, the bracket escape hatch inlines a raw value: bg-[oklch(0.6_0.2_180)] (underscores stand in for spaces). Reach for it rarely, and treat a repeated arbitrary color as a prompt to add a real token.

The hex-is-read-only rule. Don’t ship hex literals in new code; OKLCH is the storage form for color. The only color keywords that compile to themselves, and so are always fine to write, are transparent and currentColor. You will still read hex constantly, in legacy files and copied snippets, and that is fine. The rule is about what you write.

First, the decision. Sort each scenario onto the tool that fits: does the whole element need to fade, or just one layer?

For each scenario, decide whether the whole element should fade (opacity) or just one layer should fade while the content on top stays crisp (alpha). Drag each item into the bucket it belongs to, then press Check.

opacity-* Fade the whole element and its children
/N alpha Fade one declaration; content on top stays crisp
A disabled Save button
A card greyed out while its request is pending
A whole panel dimmed during a loading state
A dialog backdrop with sharp text on top
A translucent hairline border on a dark surface
A glass-morphism header tint

Second, the model. The point of OKLCH is channel independence, so what changes when you move only the L channel?

You bump a token from oklch(0.6 0.18 255) to oklch(0.7 0.18 255) — only the L value moved, C and H are untouched. What does the swatch do on screen?

Brightens, while staying exactly as blue and as saturated as before.
Brightens, but also leans a little toward purple.
Brightens, and visibly loses some of its punch — closer to grey-blue.
Stays put — at this L the change is too small to register.

A few references worth a bookmark: the canonical spec page for the mixer, an interactive way to build OKLCH intuition, the essay that made the case for the color space, and the tool that settles every contrast question.