Skip to content
Chapter 21Lesson 2

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

This lesson is how you author color in a modern CSS and Tailwind project, using the OKLCH color space, the color-mix() function, and per-property alpha to store, derive, and fade the colors behind your design tokens.

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;

That hex renders identically on every screen, which is its one virtue. But “eight percent brighter” is not something you can express, because hex has no syntax for it. To get the hover color you would reach for a JavaScript color library, compute the lighter shade at build time, and store it as a second token. Then you would do the same again 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 border-border and let the theme decide the values. You write bg-blue-500/50 for a half-transparent background. And you have seen oklch(...) values in the dark-mode tokens without anyone telling you why that color space. This lesson fills in the part that was skipped, so that you move from a consumer of color tokens to an author of them. You will learn OKLCH as the space colors are stored in, and color-mix() as the function that derives related colors at runtime. You will see what bg-blue-500/50 actually compiles to, when to reach for opacity versus per-property alpha, and the contrast check that judges every one of these choices.

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

OKLCH describes a color with three independent dials:

  • L, lightness, from 0 to 1 (you will also see it written as a percentage, 0% to 100%). oklch(1 0 0) is pure white; oklch(0 0 0) is pure black. The important property is that it is perceptually uniform: equal numeric steps look like equal brightness steps to your eye. Going from 0.4 to 0.5 is the same visual jump as 0.7 to 0.8. No other common color space gives you that.
  • C, chroma, the saturation. 0 is fully grey; around 0.4 is about as vivid as colors get. The spec puts no hard ceiling on it, but in practice the maximum depends on the lightness and hue you picked, because very light and very dark colors simply can’t be as saturated.
  • H, hue, an angle from 0 to 360 degrees around the color wheel, the same wheel you may know from HSL. Red sits near 30, green near 145, blue near 260.

The fastest way to build intuition is to 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 you are looking at, the same value you could paste straight into a token.

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

Now for the part that earns OKLCH its place as the default. There are two reasons an experienced engineer reaches for it, and the first is one you have to see rather than be told.

In OKLCH, changing one channel does not disturb the others. A blue made 10% lighter stays just as blue and just as saturated, and only its brightness moves. That sounds obvious, like it should be true of any color system. It is not true of HSL, the space most developers learned 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 a different perceived hue. A “10% lighter blue” comes out washed-out and slightly violet, like a different color entirely. This is the exact problem that 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 a single lightness slider. The left swatch is OKLCH; the right is HSL, both starting from the same blue. Drag the slider up and watch them diverge: the OKLCH swatch climbs in brightness while holding its color, and the HSL swatch slides toward grey and violet as it lightens.

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

That divergence is the reason the lesson keeps returning to OKLCH. Once you have seen it, “store colors in OKLCH” stops being a style preference and becomes the obvious choice for any system where you intend to 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 monitors from twenty years ago. Modern displays show a wider gamut called P3 , with visibly more vivid greens and reds. OKLCH can address those colors; sRGB syntax simply cannot name them.

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

This is not a niche choice you are opting into. 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 in snippets you copy from elsewhere, 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() is the tool that puts that to use: it blends two colors in the browser, at runtime, so you can derive a related color instead of storing it.

Here is the canonical form, the one you will write most:

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

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

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

The interpolation space matters for the same reason the HSL drift did, just applied to a blend rather than a single lightness change. Mixing two colors 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 clearest demonstration is the most extreme mix: blend pure blue with pure red, 50/50. The two swatches below do exactly that, with the interpolation space as the only difference. In OKLCH you get a vivid purple, the color you’d expect. In sRGB you get a dim, greyed-down purple: the same two inputs, a different road between them.

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.

With the space settled, 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 same principle you met in the cascade chapter, in “Custom properties and the three-tier token model”: express a state by deriving it, not by minting a new --primary-hover token. There you used opacity for it. color-mix() extends the move to lightness, so now 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 is enough to read as “warmer than plain grey” without becoming a colored panel.

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

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

That last one is the bridge to the next section, because mixing toward transparent is exactly what Tailwind does internally every time you write a /N opacity modifier. You have been calling color-mix() indirectly since the very first Tailwind lesson.

One thing you do not need to check before reaching for it is browser support. 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 very first Tailwind lesson, “Utility-first on JSX,” as part of the variant grammar: bg-blue-500/50 for a half-opacity background. You were told what it did, not how. Here is the how, and it connects directly to 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. Every one of those compiles to a color-mix() call. bg-blue-500/50 becomes:

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

So the modifier was never magic. 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. Both the theme swap and the alpha apply together. The old limitation, “you can’t put an opacity on a color that came from a CSS variable,” is gone in Tailwind v4 precisely because of this.

The space here is oklab, not oklch. That is a real distinction rather than a typo, so it is worth a careful look. Tailwind’s internal alpha mix uses in oklab, while the manual mixer you write for tints uses in oklch. OKLAB is the same color model as OKLCH but laid out on straight axes with no separate hue channel, which makes it ideal for a straight-line fade. Here the difference between the two has no effect: when you mix toward transparent, there is no second hue to preserve, so oklab and oklch produce the same visual result. The two spaces only diverge when you are blending two real colors; with one side transparent, they are interchangeable.

The walkthrough below traces a bg-black/50 overlay from the class you type to the CSS it generates.

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 interpolation space is oklab, the straight-line sibling of OKLCH, which is why it 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 old can’t-fade-a-CSS-variable limitation.

1 / 1

You 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). That sets up the decision in the next section, because there is a second way to make things see-through, and picking the wrong one is a real bug.

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. They are not interchangeable, and the difference is not cosmetic: it changes what gets faded. Choosing between them is a design call, and getting it wrong produces a specific, recognizable bug.

opacity-* makes the entire element semi-transparent, including every child inside it. It is a compositing operation on the whole subtree: the browser renders the element and all its descendants, then fades the finished result as one image. This is the same opacity you met in the cascade chapter’s look-alike note: it looks like it changed a color, but it composited rendered pixels. It also creates a stacking context, the way transform and filter do, so keep the layout chapter’s “Stacking context and z-index” in mind when a child needs to escape its parent’s stacking.

That whole-subtree fade is exactly right when the whole control should read as inactive, such as a disabled button or a pending card waiting on a request. You want the label, the icon, and the border all dimmed 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, and nothing else. This is what you want for a dialog backdrop: the dimmed layer should be translucent, but any text or icon sitting on top of it must stay fully opaque and readable.

The bug appears when you reach for the wrong one there. Put opacity-50 on the overlay and you fade the whole overlay, including the text on top of it, which goes half-transparent and hard to read. Use bg-black/50 and only the black background fades, while the text stays crisp.

The two tabs below run the same overlay-and-text markup. The first uses opacity-50, so the centered text fades with the backdrop, which is wrong. The second uses bg-black/50, so the text stays solid over a dimmed background, which is right.

<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 on top both drop to 50%, and the text turns muddy and hard to read.

Seeing the rendered difference side by side makes it concrete: the same markup and the same text, but one is legible and one is not.

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

Identical markup, both panels. On the left opacity-50 composites the whole overlay, so the message fades with the backdrop; on the right bg-black/50 dims only the background color 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 from the dark-mode lesson, “Dark mode via semantic tokens,” and the three-tier model in “Custom properties and the three-tier token model”: 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: OKLCH triples. It also explains why the dark-mode swap was so clean. 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);
}

Look at what changed between the two blocks for the surface color: only the L channel. 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 from the dark-mode lesson, 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. So re-pointing one variable re-themed every bg-card in the app, because the variable holds an OKLCH value you can darken by moving a single number.

When a semantic surface needs a hover state, you have both tools from this lesson available. 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 recurring case. When a state shows up again and again across the app, that is the signal to promote it to a token rather than re-derive it everywhere, the same promotion threshold from the three-tier token lesson.

You have next-themes wired up from “Theme switching without FOUC,” flipping .dark on the <html> element. There are actually two signals telling your app whether to go dark, and it is worth knowing which is which, even though they are reconciled for you.

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

These can disagree: a user whose system is in dark mode might still flip your app to light. next-themes resolves the precedence. With defaultTheme="system" (the setting from the theme-switching lesson), the app follows the OS preference until the user touches the in-app toggle, after which the site preference wins and persists. You do not arbitrate this by hand.

The Tailwind side fits in cleanly. The dark: variant compiles to a class-based selector, the @custom-variant dark rule from the dark-mode lessons. It is wrapped so it carries .dark’s single-class specificity and never fights utility ordering. So the markup below needs no dark: at all: you write bg-card text-foreground once and both themes work, because the tokens flip underneath. The only dark: you should still reach for is a genuine one-off, like a single shadow or overlay that needs a different value in dark mode.

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

Two more media-feature variants are worth recognizing. Most projects never override their defaults, but they come up in accessibility audits. contrast-more: targets users who turn on high-contrast mode in their OS (prefers-contrast: more), and forced-colors: targets Windows High Contrast Mode , which replaces your palette entirely with a system one (forced-colors: active). The next lesson, “Breakpoints and the mobile-first reflex,” covers the media-query mechanics behind this whole prefers-* family; here you just need to know the names exist.

Every color choice you just learned to make can quietly fail the one standard 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 WCAG 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.

Contrast belongs here because every color-mix() tint and every lightness choice you now make can slide below the threshold without you noticing. The -foreground half of every token pair exists precisely to guarantee the on-surface text passes: card-foreground is tuned to clear 4.5:1 against card. OKLCH helps you reason about it too, because L is perceptually uniform, so “is this text dark enough against that background” becomes a question about a single, readable number.

The playground below makes the constraint visible. Drag the lightness of the text color and watch the contrast ratio climb or fall against the fixed background. There is a line at 4.5:1, and you can see exactly where you cross it.

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

In practice you rarely tune these by hand. Chrome DevTools’ color picker shows the live contrast ratio and an AA/AAA pass badge right next to the swatch, so pop it open on any text element and the verdict is there. And the Tailwind palette shadcn ships has already been contrast-audited, which is one more reason to stay on semantic tokens instead of hand-picking values: the work has been done for you.

This is the working slice of accessibility, enough to keep your day-to-day color choices honest. The full commitment, the audit workflow, and the tooling come later, in the chapter on the accessibility baseline.

A few more color utilities round out the toolbox. Each is a quick reach, not a new model.

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

Arbitrary color values. When a genuine one-off design need can’t be expressed with a token, the bracket escape hatch lets you inline a raw value: bg-[oklch(0.6_0.2_180)] (underscores stand in for the spaces). This sits on the same escape-hatch ladder as the rest of Tailwind’s arbitrary values from “Utility-first on JSX.” Reach for it rarely, and treat a repeated arbitrary color as a prompt to add a real token, the same way a repeated arbitrary spacing value is a prompt to grow the scale.

The hex-is-read-only rule. Do not ship hex literals in new code, because 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.

Two ideas carry the most weight from this lesson: the OKLCH-plus-color-mix() model, and the opacity-versus-alpha decision. The questions below test both.

First, the decision. Sort each scenario onto the tool that fits it. 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 whole point of OKLCH is channel independence, so what actually 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.