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.

scripts/palette-audit.mjs
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:

StepLightness across familiesMean chromaContrast with whiteOutside sRGB
500.962 to 0.9870.0181.03 to 1.124 of 17
1000.930 to 0.9730.0421.07 to 1.234 of 17
2000.870 to 0.9450.0801.16 to 1.498 of 17
3000.785 to 0.9050.1351.30 to 2.0111 of 17
4000.673 to 0.8520.1891.54 to 3.1215 of 17
5000.585 to 0.7950.2141.91 to 4.5814 of 17
6000.511 to 0.6810.2132.93 to 6.4413 of 17
7000.457 to 0.5550.1874.92 to 8.0714 of 17
8000.398 to 0.4760.1546.87 to 10.0910 of 17
9000.359 to 0.4210.1248.69 to 11.471 of 17
9500.257 to 0.3020.08813.39 to 16.141 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:

HueL = 0.5L = 0.7L = 0.9
30 (red)0.2010.1920.052
90 (yellow)0.1020.1430.128
150 (green)0.1380.1930.182
210 (teal)0.0870.1210.090
270 (blue)0.2810.1560.048
330 (magenta)0.2280.3140.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

  1. 50L 0.977
    C 0.011
    white1.1, fails 3 to 1dark18.5, passes 4.5 to 1
  2. 100L 0.950
    C 0.024
    white1.2, fails 3 to 1dark17.1, passes 4.5 to 1
  3. 200L 0.905
    C 0.047
    white1.3, fails 3 to 1dark15.0, passes 4.5 to 1
  4. 300L 0.840
    C 0.082
    white1.6, fails 3 to 1dark12.2, passes 4.5 to 1
  5. 400L 0.754
    C 0.131
    white2.2, fails 3 to 1dark9.1, passes 4.5 to 1
  6. 500L 0.683
    C 0.150
    white2.8, fails 3 to 1dark7.0, passes 4.5 to 1
  7. 600L 0.598
    C 0.149
    white4.0, passes 3 to 1 onlydark5.0, passes 4.5 to 1
  8. 700L 0.515
    C 0.131
    white5.6, passes 4.5 to 1dark3.5, passes 3 to 1 only
  9. 800L 0.446
    C 0.108
    white7.6, passes 4.5 to 1dark2.6, fails 3 to 1
  10. 900L 0.395
    C 0.087
    white9.4, passes 4.5 to 1dark2.1, fails 3 to 1
  11. 950L 0.278
    C 0.062
    white14.7, passes 4.5 to 1dark1.3, fails 3 to 1
app.css
@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.

A 50 to 950 scale from one hue. Contrast is measured against white and against Tailwind's neutral-950, the page color of a dark UI. A dot marks a step outside sRGB.

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.

JobOn a white pageOn a dark page (neutral-950)
Tints and surfaces50 to 200900 and 950
Borders that identify a control (3:1)600 and darker700 and lighter
Body text and icons (4.5:1)700 and darker600 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:

app.css
@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:

app.css
: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.