Tailwind v4 OKLCH + color-mix() — Design Tokens That Auto-Generate Tints and Shades

Tailwind v4 ships OKLCH and color-mix() under the hood. Define one brand token, derive the whole tint and shade ramp at runtime, no precomputed palette.

Steven Richardson
Steven Richardson
· 8 min read

You upgrade a Laravel project to Tailwind v4, port your brand colour into @theme as the hex value you have used for years, and bg-brand/50 quietly stops behaving like it used to. The change-log line that mattered — "the default palette is now OKLCH" — looked cosmetic, so nobody read further. It was not cosmetic. Tailwind v4's whole colour system is built on OKLCH plus color-mix(), and once your token speaks that language you get tints, shades, and opacity modifiers for free.

If you have not migrated yet, the Tailwind v3 to v4 Laravel upgrade walkthrough covers the codemod. The rest of this assumes you are already on v4 and wondering why the colour system suddenly feels different.

Why OKLCH replaced RGB in the default palette#

OKLCH stands for OK Lightness, Chroma, Hue. It is the colour space your eyes actually use to compare two swatches — equal jumps in L look like equal jumps in brightness, regardless of the hue. HSL pretends to do that but does not: a yellow at 50% lightness looks vastly brighter than a blue at 50% lightness because HSL is doing maths in RGB space and calling the result perceptual.

That mismatch is the reason your hand-curated brand-50 through brand-950 ramp in v3 always needed a designer to nudge the middle stops. OKLCH removes the need for the nudge. If two tokens have the same L, they will read as the same brightness on screen, full stop. That is also why WCAG contrast becomes far more predictable — the L value is a strong proxy for perceived lightness, so picking accessible pairs is closer to arithmetic than guesswork.

Tailwind's default palette in v4 is published as CSS variables like --color-blue-500: oklch(62.3% 0.214 259.815). You can crack open node_modules/tailwindcss/theme.css and read it. Every shade across every colour is OKLCH. No more "blue feels heavier than yellow at the same step" — they actually match.

Defining a single brand token in @theme#

The v4 way to add a colour is the @theme directive in your CSS file. Forget tailwind.config.js — it is gone. Drop one line in your main stylesheet and every utility that takes a colour can now use your brand token.

@import "tailwindcss";

@theme {
    --color-brand: oklch(59% 0.24 255);
    --color-brand-contrast: oklch(98% 0.02 255);
}

That single declaration is enough to give you bg-brand, text-brand, border-brand, ring-brand, from-brand, to-brand, and every opacity modifier (bg-brand/30, text-brand/70). The --color-brand-contrast token is the one I always pair with the brand colour for text-on-brand surfaces — high L, low chroma, same hue family. It is a single line of CSS replacing what used to be a JSON tree.

If you already use a CSS-first approach for the dark variant, the same @theme block is where it lives. The CSS-first dark mode setup with @custom-variant shows how those two patterns sit next to each other in one stylesheet.

color-mix() is the engine — opacity, tints, shades#

color-mix() is a CSS function that blends two colours in a colour space you choose. The signature is color-mix(in <space>, <color> <percent>, <color> <percent>). Tailwind v4 leans on it heavily — your bg-brand/50 is no longer a multi-variable opacity dance, it compiles to a single color-mix() call against transparent.

<button class="bg-brand/50 hover:bg-brand text-brand-contrast">
    Subscribe
</button>

That compiles to something like:

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

The opacity modifier uses oklab for the alpha mix (a sibling space to OKLCH that is better for alpha blending), but the principle is the same — one function replaces every --tw-bg-opacity CSS variable from v3.

For tints and shades, you reach for color-mix() directly. Mixing against white gives a tint, mixing against black gives a shade — and because the maths happens in OKLCH, the hue stays put.

@theme {
    --color-brand: oklch(59% 0.24 255);

    /* tints — mix toward white */
    --color-brand-100: color-mix(in oklch, var(--color-brand) 12%, white);
    --color-brand-200: color-mix(in oklch, var(--color-brand) 25%, white);
    --color-brand-300: color-mix(in oklch, var(--color-brand) 40%, white);
    --color-brand-400: color-mix(in oklch, var(--color-brand) 70%, white);

    /* base */
    --color-brand-500: var(--color-brand);

    /* shades — mix toward black */
    --color-brand-600: color-mix(in oklch, var(--color-brand) 85%, black);
    --color-brand-700: color-mix(in oklch, var(--color-brand) 70%, black);
    --color-brand-800: color-mix(in oklch, var(--color-brand) 55%, black);
    --color-brand-900: color-mix(in oklch, var(--color-brand) 35%, black);
}

You now have bg-brand-100 through bg-brand-900 from one source of truth. Change --color-brand and the whole ramp moves with it — useful for white-label work, theme switchers, or just iterating on the brand without re-running a palette generator.

v3 hardcoded ramp vs v4 derived ramp#

The contrast with the old approach is the part that sells this. In v3 you owned every step.

// tailwind.config.js — Tailwind v3
module.exports = {
    theme: {
        extend: {
            colors: {
                brand: {
                    50:  '#eef2ff',
                    100: '#e0e7ff',
                    200: '#c7d2fe',
                    300: '#a5b4fc',
                    400: '#818cf8',
                    500: '#6366f1',
                    600: '#4f46e5',
                    700: '#4338ca',
                    800: '#3730a3',
                    900: '#312e81',
                    950: '#1e1b4b',
                },
            },
        },
    },
}

Ten hand-picked hex values, all unrelated to each other as far as the engine is concerned. Change the 500 and you re-run a generator (or worse, ask a designer to pick the rest).

/* app.css — Tailwind v4 */
@import "tailwindcss";

@theme {
    --color-brand: oklch(59% 0.24 255);
}

One value. Tints and shades derive from it — explicitly via color-mix() if you want named steps, or implicitly via the /<alpha> modifier whenever you need opacity. The system became smaller, not larger.

Browser support and graceful fallbacks#

Both OKLCH and color-mix() are Baseline in 2026 — Chrome, Safari, Firefox, and Edge all ship full support. Tailwind v4 itself targets Safari 16.4+, Chrome 111+, and Firefox 128+, which is exactly the cohort where both features are stable. If you have an analytics floor below that you would already have noticed; if not, you are safe.

For the rare visitor on an older browser, Tailwind's compiled output degrades politely — opacity modifiers fall back to the solid colour, OKLCH values resolve to their nearest sRGB neighbour. Nothing breaks; the page just looks a touch less vibrant for that 1–2% of traffic. I have never had to add explicit fallbacks in production.

Gotchas and edge cases#

A few things to know before you ship this:

A pasted hex value in @theme will work but skips every benefit in this article. --color-brand: #4f46e5 gives you opacity modifiers (Tailwind still wraps it in color-mix(in srgb, ...)) but you lose perceptual uniformity for tint and shade derivations. Use oklch().

Colour pickers still default to HSL or RGB. oklch.com and oklch.fyi are the two I use to convert a brand hex into an oklch() value with the chroma capped to the sRGB gamut. Without gamut-capping you can produce OKLCH values that look gorgeous on a P3 display and dull on a sRGB laptop.

The interpolation space for opacity is oklab, not oklch. They are sibling spaces and the visual result is identical for alpha — just do not be surprised when you inspect the DevTools output and see oklab there. For tint/shade derivations you choose the space yourself in the color-mix() call, so use in oklch to keep the hue stable.

If you also build with v4 gradient utilities like bg-linear-to and bg-radial, they accept the same OKLCH colour stops automatically — gradients between two OKLCH tokens interpolate smoothly without the muddy halfway point you get in sRGB.

Wrapping Up#

Pick one brand colour, write it in OKLCH inside @theme, and let color-mix() generate the rest of the ramp. You shrink your token surface area, you stop maintaining a precomputed JSON palette, and your opacity modifiers become a single CSS function call. The default v4 palette already does this for the named colours — you are just opting your own brand into the same system.

Next, look at how @theme plays with other v4 features: the container queries pattern for component-first responsive design uses the same theme variable approach, and you can layer everything on top of the CSS-first dark mode setup without touching a config file.

FAQ#

What is OKLCH and why does Tailwind v4 use it?

OKLCH is a perceptually uniform colour space — equal numerical jumps in L (lightness) look like equal jumps in brightness on screen, regardless of hue. Tailwind v4 adopted it because RGB and HSL palettes always need hand-tuning at the midtones, and OKLCH removes that need. The default palette and every opacity calculation now happen in OKLCH or its sibling oklab, which is why colour interpolation in v4 looks noticeably cleaner than v3.

How do I define a custom design token in Tailwind v4?

Add an @theme block to the CSS file where you import Tailwind, and declare a CSS variable named after the token type. For a colour, that looks like @theme { --color-brand: oklch(59% 0.24 255); }. Tailwind then exposes utilities for it automatically — bg-brand, text-brand, border-brand, ring-brand, and every opacity modifier such as bg-brand/50. No tailwind.config.js is needed; v4 dropped that file entirely.

How does color-mix() work in Tailwind v4 opacity modifiers?

When you write bg-brand/50, Tailwind compiles it to a color-mix() call that blends your colour with transparent at the chosen percentage. The output looks like color-mix(in oklab, var(--color-brand) 50%, transparent). This replaced the --tw-bg-opacity CSS variable plumbing from v3, which is why opacity modifiers now compose with custom tokens out of the box without any extra setup.

Why is OKLCH better than HSL or RGB for design tokens?

Because the L channel actually correlates with how light a colour appears to your eye. In HSL, a yellow at 50% lightness looks far brighter than a blue at 50% lightness — HSL does the maths in RGB space and the perceptual gap shows. OKLCH fixes that, which means tints, shades, and WCAG contrast all become predictable. A 30% darker OKLCH brand stays the same colour, just darker; the same operation in RGB or HSL drifts the hue.

Do all browsers support OKLCH and color-mix() in 2026?

Yes — both are Baseline in 2026, fully supported in Chrome, Safari, Firefox, and Edge across desktop and mobile. Tailwind v4 officially targets Safari 16.4+, Chrome 111+, and Firefox 128+, which all ship full support. For the tiny slice of older browsers, the compiled output falls back to solid colours and nearest-sRGB approximations — nothing breaks, the page just renders slightly less vibrant.

How do I generate a full tint and shade palette from a single OKLCH color?

Use color-mix(in oklch, ...) to blend your base token with white for tints and black for shades, then alias each result as its own theme variable. A typical ramp uses 12%, 25%, 40%, 70% mixes toward white for the 100–400 steps and 85%, 70%, 55%, 35% toward black for the 600–900 steps, with the base colour as the 500. Because the mixing happens in OKLCH, the hue stays stable across the whole ramp — you do not get the muddy midtone shift you would see in sRGB.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.