An OKLCH color palette is a scale of colors that share a hue and step through lightness and chroma, written as oklch(L C H). Tailwind CSS v4 ships its default palette in that form, so it is the obvious thing to measure before building your own. It doesn't behave the way the usual advice says.
At the same step, lightness differs between hues: step 500 runs from 0.585 to 0.795. Step 600 reaches 4.5:1 as text on white for only seven of the seventeen colored families. And at step 400, fifteen of the seventeen colors sit outside sRGB. Below are the numbers, the script that produced them, a builder that follows the same logic, and the @theme block it exports.
What an OKLCH scale is made of
An OKLCH color has three channels. MDN describes L as the perceived lightness, which is different from the L in hsl(). C is chroma, the amount of color: it starts at 0 for a grey and in practice stays under 0.5. H is the hue angle in degrees. Björn Ottosson designed Oklab, the space OKLCH is built on, as a perceptual color space: its lightness, chroma and hue are meant to line up with how colors look.
A scale holds the hue still and moves the other two. Lightness does most of the work, because contrast with a page background is mostly a matter of lightness. Chroma is the part you shape: low at the pale and dark ends, strongest in the middle. The design tokens guide shows why this beats HSL for contrast. At a fixed OKLCH lightness of 0.53, contrast with the page stays between 4.59 and 5.40 to 1 across the whole hue circle, where fixed HSL lightness swings from 1.80 to 9.61.
What the default Tailwind palette does
Tailwind CSS 4.3.3 includes its palette in theme.css as oklch() values. The script below reads that file, keeps the 17 families that have real chroma (the greys are left out), and measures four things per step: the lightness range across families, the mean chroma, the contrast of each color against white, and how many fall outside sRGB.
import { readFileSync } from "node:fs";
const css = readFileSync("node_modules/tailwindcss/theme.css", "utf8");
const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
// { red: { 50: { l, c, h }, 100: ... }, ... } from lines like
// --color-red-500: oklch(63.7% 0.237 25.331);
const palette = {};
for (const [, name, step, l, c, h] of css.matchAll(/--color-([a-z]+)-(\d+):\s*oklch\(([\d.]+)%\s+([\d.]+)\s+([\d.]+)\)/g)) {
(palette[name] ??= {})[step] = { l: l / 100, c: +c, h: +h };
}
// The greys have almost no chroma; keep the colored families.
const families = Object.keys(palette).filter((name) => palette[name][500].c > 0.1);
// OKLCH to linear sRGB (Ottosson's matrices), then WCAG 2 contrast against white.
function linear({ l, c, h }) {
const a = c * Math.cos((h * Math.PI) / 180);
const b = c * Math.sin((h * Math.PI) / 180);
const [L, M, S] = [l + 0.3963377774 * a + 0.2158037573 * b, l - 0.1055613458 * a - 0.0638541728 * b, l - 0.0894841775 * a - 1.291485548 * b].map((v) => v ** 3);
return [4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S, -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S, -0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S];
}
const insideSrgb = (rgb) => rgb.every((v) => v > -1e-4 && v < 1 + 1e-4);
const luminance = (rgb) => {
const [r, g, b] = rgb.map((v) => Math.min(1, Math.max(0, v)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const onWhite = (color) => 1.05 / (luminance(linear(color)) + 0.05);
const range = (values, digits = 2) => `${Math.min(...values).toFixed(digits)} to ${Math.max(...values).toFixed(digits)}`;
console.log(`${families.length} colored families`);
for (const step of STEPS) {
const colors = families.map((name) => palette[name][step]);
const meanChroma = colors.reduce((sum, color) => sum + color.c, 0) / colors.length;
const outside = colors.filter((color) => !insideSrgb(linear(color))).length;
console.log(
`${step}: L ${range(colors.map((color) => color.l), 3)}, mean C ${meanChroma.toFixed(3)}, on white ${range(colors.map(onWhite))}, outside sRGB ${outside}`
);
}
// The first step that reaches 4.5:1 as text on white, per family.
const first = {};
for (const name of families) {
const step = STEPS.find((s) => onWhite(palette[name][s]) >= 4.5);
(first[step] ??= []).push(name);
}
for (const step of Object.keys(first)) console.log(`${step}: ${first[step].join(", ")}`);Running it against Tailwind CSS 4.3.3 gives this table:
| Step | Lightness across families | Mean chroma | Contrast with white | Outside sRGB |
|---|---|---|---|---|
| 50 | 0.962 to 0.987 | 0.018 | 1.03 to 1.12 | 4 of 17 |
| 100 | 0.930 to 0.973 | 0.042 | 1.07 to 1.23 | 4 of 17 |
| 200 | 0.870 to 0.945 | 0.080 | 1.16 to 1.49 | 8 of 17 |
| 300 | 0.785 to 0.905 | 0.135 | 1.30 to 2.01 | 11 of 17 |
| 400 | 0.673 to 0.852 | 0.189 | 1.54 to 3.12 | 15 of 17 |
| 500 | 0.585 to 0.795 | 0.214 | 1.91 to 4.58 | 14 of 17 |
| 600 | 0.511 to 0.681 | 0.213 | 2.93 to 6.44 | 13 of 17 |
| 700 | 0.457 to 0.555 | 0.187 | 4.92 to 8.07 | 14 of 17 |
| 800 | 0.398 to 0.476 | 0.154 | 6.87 to 10.09 | 10 of 17 |
| 900 | 0.359 to 0.421 | 0.124 | 8.69 to 11.47 | 1 of 17 |
| 950 | 0.257 to 0.302 | 0.088 | 13.39 to 16.14 | 1 of 17 |
The palette doesn't hold lightness constant per step. The spread is widest at step 500, where amber-500 has a lightness of 0.769 and blue-500 has 0.623. A scale that copies one lightness value to every hue would be tidier, but Tailwind's isn't built that way.
Contrast varies along with lightness, so the step where text first passes depends on the hue. The script's last block prints the grouping: indigo passes at 500, seven families (red, blue, violet, purple, fuchsia, pink and rose) at 600, and nine (orange, amber, yellow, lime, green, emerald, teal, cyan and sky) at 700. Step 700 is the first step where all seventeen pass, with a lowest ratio of 4.92:1.
Much of the scale is also wider than sRGB. At step 400, fifteen of the seventeen families fall outside it, and at 500 and 700 it's fourteen. Tailwind's v4 announcement says the palette moved from rgb to oklch to take advantage of the wider gamut, so this is by design. On a display that can't show those colors, the browser maps them in, which is covered below.
One caveat on the contrast column. WCAG 2 defines relative luminance for the sRGB color space, so the script clips each color to sRGB before computing it. For a wide-gamut color the ratio on a P3 screen can differ a little from the figure here.
Choose lightness first, then chroma
The scale you build can borrow the structure without copying the table. Fix lightness per step, because lightness is what contrast follows. Set a peak chroma and let each step take a share of it. Keep the hue constant.
The builder further down uses the mean of Tailwind's 17 families for lightness: 0.977, 0.950, 0.905, 0.840, 0.754, 0.683, 0.598, 0.515, 0.446, 0.395 and 0.278 for steps 50 to 950. For chroma it uses the shape of the mean chroma column above, as a fraction of the peak. Chroma is highest at 500 and 600 and falls away on both sides, because pale and dark colors have little room for it.
Fit the chroma to the gamut
Asking for the same chroma at every step doesn't work, because the amount of chroma sRGB can hold depends on both lightness and hue. These are the highest in-gamut values at three lightness levels, computed by scanning outwards from grey until the color leaves sRGB:
| Hue | L = 0.5 | L = 0.7 | L = 0.9 |
|---|---|---|---|
| 30 (red) | 0.201 | 0.192 | 0.052 |
| 90 (yellow) | 0.102 | 0.143 | 0.128 |
| 150 (green) | 0.138 | 0.193 | 0.182 |
| 210 (teal) | 0.087 | 0.121 | 0.090 |
| 270 (blue) | 0.281 | 0.156 | 0.048 |
| 330 (magenta) | 0.228 | 0.314 | 0.085 |
At a lightness of 0.9, yellow can hold 0.128 and blue only 0.048, so a pale blue with the chroma of a pale yellow isn't a color a regular display can show. At 0.5 the positions swap: blue holds 0.281 and teal 0.087.
When the requested chroma is too high, the browser has to bring the color back into the display's gamut. CSS Color 4 specifies three algorithms for this, all doing "constant-lightness, constant-hue chroma reduction in the OkLCh color space", and lets implementations choose among them. In practice that means you get a close, less saturated color, and not necessarily the same one in every browser. If you want to know what you are shipping, cap the chroma yourself. That is what "fit chroma to sRGB" does in the builder.
Try it
@theme {
--color-brand-50: oklch(97.7% 0.011 250);
--color-brand-100: oklch(95% 0.024 250);
--color-brand-200: oklch(90.5% 0.047 250);
--color-brand-300: oklch(84% 0.082 250);
--color-brand-400: oklch(75.4% 0.131 250);
--color-brand-500: oklch(68.3% 0.15 250);
--color-brand-600: oklch(59.8% 0.149 250);
--color-brand-700: oklch(51.5% 0.131 250);
--color-brand-800: oklch(44.6% 0.108 250);
--color-brand-900: oklch(39.5% 0.087 250);
--color-brand-950: oklch(27.8% 0.062 250);
}Every step keeps as much chroma as sRGB can hold at its lightness and hue, up to the peak you set. On white, step 700 is the first to reach 4.5:1 for body text.
With the defaults (hue 250, peak chroma 0.15, fitting on), step 600 reaches 3.97:1 on white. That is under the 4.5:1 that WCAG 2.2 success criterion 1.4.3 asks of body text, and above the 3:1 it allows for large text, which means 18 point or 14 point bold. Step 700 reaches 5.62:1. Turn fitting off and raise the peak chroma to see which steps run out of room first: the pale ones, at hues like blue.
Which step does which job
The demo's output gives a usable map for that hue. Other hues move the boundaries a step either way, so check yours before relying on it.
| Job | On a white page | On a dark page (neutral-950) |
|---|---|---|
| Tints and surfaces | 50 to 200 | 900 and 950 |
| Borders that identify a control (3:1) | 600 and darker | 700 and lighter |
| Body text and icons (4.5:1) | 700 and darker | 600 and lighter |
The 3:1 row comes from success criterion 1.4.11, which asks for that ratio for the visual information needed to identify user interface components and their states. The 4.5:1 row is 1.4.3.
A scale gives you eleven steps, and a layout should use few of them. The bento grid design guide makes the same argument for tile colors: one lead color and one or two accents, with the rest left neutral.
Export it to @theme
Variables in Tailwind's --color-* namespace become utilities, so a scale defined in @theme gives you bg-brand-500, text-brand-700 and border-brand-600 with no further setup. This is the block the builder produces at its defaults:
@theme {
--color-brand-50: oklch(97.7% 0.011 250);
--color-brand-100: oklch(95% 0.024 250);
--color-brand-200: oklch(90.5% 0.047 250);
--color-brand-300: oklch(84% 0.082 250);
--color-brand-400: oklch(75.4% 0.131 250);
--color-brand-500: oklch(68.3% 0.15 250);
--color-brand-600: oklch(59.8% 0.149 250);
--color-brand-700: oklch(51.5% 0.131 250);
--color-brand-800: oklch(44.6% 0.108 250);
--color-brand-900: oklch(39.5% 0.087 250);
--color-brand-950: oklch(27.8% 0.062 250);
}This adds a new family next to the defaults. To drop Tailwind's palette and keep only yours, Tailwind's documentation shows setting the whole namespace to initial with --color-*: initial; at the top of the @theme block.
Components shouldn't use the steps directly. Point semantic tokens at them, so a theme change is one edit. The semantic layer is where the white-page and dark-page columns of the table above get applied:
:root { --brand: var(--color-brand-700); }
.dark { --brand: var(--color-brand-400); }
@theme inline {
--color-brand: var(--brand);
}@theme inline is there because --color-brand refers to another variable, as explained in the guide to design tokens in Tailwind v4.
Check before you ship
Run the real pairs through a contrast check, with the text sizes and backgrounds they will appear on. Look at the scale on the screens you can. A color fitted to sRGB doesn't depend on a browser's gamut mapping, and one that wasn't may. And keep the numbers you chose, not only the swatches. A scale written down as lightness, chroma and hue can be regenerated for a new brand color in a minute; a scale that exists only as eleven hex codes has to be rebuilt by eye.
