Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 6 – Colour and theming

OKLCH, Oklab and wide-gamut colour

Write colours in oklch(), build evenly stepped palettes by OKLCH lightness, and use Display P3 colours with an sRGB fallback and color-gamut.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Write colours in oklch() and explain lightness, chroma and hue
  • Build an evenly stepped palette by varying OKLCH lightness
  • Predict what an sRGB screen shows for a colour outside its gamut
  • Use display-p3 colours with an sRGB fallback and the color-gamut media query

Before you start

What you’ll learn · Before you start

What you will learn

  • Write colours in oklch() and explain lightness, chroma and hue
  • Build an evenly stepped palette by varying OKLCH lightness
  • Predict what an sRGB screen shows for a colour outside its gamut
  • Use display-p3 colours with an sRGB fallback and the color-gamut media query

Before you start

On this page

OKLCH describes a colour with three numbers you can reason about: lightness, from 0 for black to 1 for white; chroma, how colourful it is, from 0 for a gray upwards; and hue, an angle around the colour wheel. You write it as oklch(0.55 0.15 262). It is the cylindrical form of Oklab, a colour space built so that equal changes in its numbers look like equal changes to the eye, which HSL never achieved. That makes OKLCH the practical choice for palettes: keep the hue, change the lightness in equal steps, and the steps look even and have contrast you can predict. OKLCH can also describe colours outside sRGB, the more vivid colours that wide-gamut screens such as Display P3 can show. On an ordinary screen those colours have to be brought back inside sRGB, and you decide how by giving a fallback.

This lesson builds the same palette in HSL and in OKLCH and measures its contrast, checks which colours fit which screens, and reads the pixels that Chromium 153, Firefox 155 and WebKit 26.6 painted for a colour an sRGB screen can’t show.

Reading oklch(): lightness, chroma and hue

  • Lightness is a number from 0 to 1, or a percentage where 100% means 1. 0 is black and 1 is white, whatever the other two numbers say.
  • Chroma is 0 for a gray. A percentage is allowed too, with 100% meaning 0.4. The most colourful sRGB colours reach about 0.32 (the gamut program below finds pure blue #0000ff at oklch(0.452 0.313 264.1)), and Display P3’s pure green reaches 0.369, so in practice chroma stays below 0.4.
  • Hue is an angle in degrees. CSS Color 4 gives the landmarks: 0 points to a purplish red, 90 to a mustard yellow, 180 to a greenish cyan and 270 to a sky blue. The angles are not those of HSL: HSL’s blue is at 240, while #0000ff sits at 264 in OKLCH.

An alpha comes after a slash, as in every modern colour function: oklch(0.55 0.15 262 / 50%). The keyword none marks a missing component, which matters mostly when colours are mixed. Commas are not allowed in oklch() at all: it was never part of the legacy syntax.

Oklab is the same space in rectangular form: oklab(0.55 -0.02 -0.15) gives the lightness, then a, from green (negative) to a purplish red (positive), and b, from blue (negative) to yellow (positive). OKLCH is easier to edit by hand, because hue and chroma are separate numbers; Oklab is what browsers use by default when they mix colours, which the next lesson covers. CSS also has the older CIE Lab and LCH as lab() and lch(). They were the first perceptual spaces in CSS, but blues in LCH drift towards purple as their chroma falls, a flaw Oklab was designed to fix. oklab(), oklch(), lab(), lch() and color() are all Baseline widely available.

A script reads an oklch() colour back in its own form, not as rgb(): getComputedStyle() reported oklch(0.4 0.15 264) for oklch(0.4 0.15 264) in all three engines, with the lightness as a number. Converting it to sRGB would lose any colour outside sRGB, so CSS Color 4 keeps it.

Color Converter Paste a hex colour to see its oklch() value, or an oklch() value to see the nearest hex.

An even palette: OKLCH against HSL

The program builds nine steps for three hues twice: once by HSL lightness from 90% to 10%, and once by OKLCH lightness from 0.95 to 0.31 with a chroma of 0.1. Each cell is the contrast of white text on that step:

White text on nine steps of HSL and OKLCH palettes JavaScript · scales.mjs
// Nine-step palettes for three hues, built once by HSL lightness and once by OKLCH lightness. Each cell is the
// contrast of white text on that step; "*" marks steps that reach 4.5:1, "!" steps outside sRGB, which a browser clips.
// Conversions follow CSS Color 4 (sRGB, XYZ and Oklab); luminance and contrast follow WCAG 2.2.

const mul = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
const XYZ_TO_SRGB = [[3.2409699, -1.5373832, -0.4986108], [-0.9692436, 1.8759675, 0.0415551], [0.0556301, -0.2039770, 1.0569715]];
const OKLAB_TO_LMS = [[1, 0.3963378, 0.2158038], [1, -0.1055613, -0.0638542], [1, -0.0894842, -1.2914855]];
const LMS_TO_XYZ = [[1.2268799, -0.5578150, 0.2813910], [-0.0405757, 1.1122868, -0.0717111], [-0.0763729, -0.4214933, 1.5869240]];

/** oklch(l c h) as sRGB channels; values below 0 or above 1 mean the colour is outside sRGB. */
function oklchToSrgb(l, c, h) {
  const rad = (h * Math.PI) / 180;
  const lms = mul(OKLAB_TO_LMS, [l, c * Math.cos(rad), c * Math.sin(rad)]).map((x) => x ** 3);
  return mul(XYZ_TO_SRGB, mul(LMS_TO_XYZ, lms)).map((x) => Math.sign(x) * toGamma(Math.abs(x)));
}

function hslToRgb(h, s, l) {
  s /= 100;
  l /= 100;
  const f = (n) => {
    const k = (n + h / 30) % 12;
    return l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1));
  };
  return [f(0), f(8), f(4)];
}

const luminance = ([r, g, b]) => 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
const onWhite = (rgb) => 1.05 / (luminance(rgb) + 0.05);
const inGamut = (rgb) => rgb.every((x) => x >= -0.0001 && x <= 1.0001);
const clip = (rgb) => rgb.map((x) => Math.min(1, Math.max(0, x)));

function cell(rgb) {
  const ratio = onWhite(clip(rgb));
  const mark = (inGamut(rgb) ? '' : '!') + (ratio >= 4.5 ? '*' : '');
  return (ratio.toFixed(2) + mark).padStart(8);
}

const hslSteps = [90, 80, 70, 60, 50, 40, 30, 20, 10];
console.log('hsl(h 90% L)'.padEnd(14) + hslSteps.map((l) => `${l}%`.padStart(8)).join(''));
for (const [name, hue] of [['blue', 220], ['green', 140], ['yellow', 50]]) {
  console.log(`${name} ${hue}`.padEnd(14) + hslSteps.map((l) => cell(hslToRgb(hue, 90, l))).join(''));
}

const okSteps = hslSteps.map((_, i) => Math.round((0.95 - i * 0.08) * 100) / 100);
console.log('oklch(L 0.1 h)'.padEnd(14) + okSteps.map((l) => l.toFixed(2).padStart(8)).join(''));
for (const [name, hue] of [['blue', 262], ['green', 150], ['yellow', 90]]) {
  console.log(`${name} ${hue}`.padEnd(14) + okSteps.map((l) => cell(oklchToSrgb(l, 0.1, hue))).join(''));
}

// Where white text starts to reach 4.5:1: the highest OKLCH lightness that still reaches it, per hue (chroma 0.1),
// found by bisection, and for a gray (chroma 0).
const highestLightness = (c, h) => {
  let low = 0;
  let high = 1;
  for (let i = 0; i < 40; i++) {
    const mid = (low + high) / 2;
    if (onWhite(clip(oklchToSrgb(mid, c, h))) >= 4.5) low = mid;
    else high = mid;
  }
  return low;
};
const limits = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((h) => highestLightness(0.1, h));
console.log(`white text reaches 4.5:1 up to lightness ${Math.min(...limits).toFixed(3)} to ${Math.max(...limits).toFixed(3)} across 12 hues, ${highestLightness(0, 0).toFixed(3)} for a gray`);

Output

hsl(h 90% L)       90%     80%     70%     60%     50%     40%     30%     20%     10%
blue 220          1.36    1.89    2.72    3.95   5.62*   7.77*  10.82*  14.71*  18.55*
green 140         1.12    1.25    1.36    1.45    1.51    2.39    4.09   7.67*  14.56*
yellow 50         1.10    1.21    1.32    1.44    1.56    2.46    4.19   7.77*  14.55*
oklch(L 0.1 h)    0.95    0.87    0.79    0.71    0.63    0.55    0.47    0.39    0.31
blue 262         1.21!   1.52!    1.94    2.59    3.52   4.89*   6.89*   9.72*  13.34*
green 150        1.14!    1.43    1.86    2.47    3.35   4.63*   6.51*   9.18* 12.50!*
yellow 90        1.18!    1.48    1.93    2.58    3.51   4.87*  6.85!*  9.60!* 13.14!*
white text reaches 4.5:1 up to lightness 0.556 to 0.581 across 12 hues, 0.568 for a gray

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node scales.mjs

The HSL rows are inconsistent. At 50% lightness, white text reaches 5.62:1 on the blue but only 1.51:1 on the green; white text becomes readable at 50% for blue but only at 20% for green and yellow. A design system that says “use step 50 for buttons with white text” would be wrong for two hues out of three.

The OKLCH rows line up. White text reaches 4.5:1 at lightness 0.55 for all three hues (4.89, 4.63 and 4.87) and fails at 0.63 for all three, and at every step the three ratios are close. In this program’s own check, white text reached 4.5:1 at a lightness between 0.556 and 0.581 depending on the hue, and at 0.568 for grays. That gives a rule of thumb you can use without measuring each colour: at OKLCH lightness 0.55 or lower, white text is readable; at 0.63 or higher, dark text is. Check every pair before you ship, but start from the rule.

The ! marks show the catch. A chroma of 0.1 is too much for the palest and the darkest steps of some hues: the colour falls outside sRGB, and the browser clips it, which shifts it. Real palettes lower the chroma at the ends of the scale. Tailwind CSS 4, for example, defines its default colour scales in OKLCH, red-500 being oklch(63.7% 0.237 25.331), with chroma that rises towards the middle steps and falls at both ends. The exercise of this lesson builds a scale that lowers chroma only where a step would leave sRGB.

Color Palette Generator Generate palettes in OKLCH and compare their steps with the ones from scales.mjs.

Wide gamut: sRGB, Display P3 and what fits where

A gamut is the range of colours a colour space or a screen can produce. sRGB is the gamut of ordinary screens and of every hex, rgb() and hsl() colour. Display P3, a wider gamut that many recent phones and laptops can show, covers more vivid greens, reds and oranges, and Rec. 2020 goes further still. color() names the space directly: color(display-p3 0 0.75 0.4) gives the red, green and blue of P3, each from 0 to 1. OKLCH has no gamut of its own, so it can describe colours of any of them. The program checks six colours:

Which colours fit sRGB and P3, and what sRGB screens show JavaScript · gamut_check.mjs
// Is a colour inside sRGB, inside Display P3, and what does it become on an sRGB screen when each channel is clipped
// to 0-1 (what Chromium 153, Firefox 155 and WebKit 26.6 painted in this lesson's screenshots)?
// Matrices from CSS Color 4: Display P3 and sRGB to XYZ (D65) and back, and Oklab.

const mul = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const toLinear = (c) => Math.sign(c) * (Math.abs(c) <= 0.04045 ? Math.abs(c) / 12.92 : ((Math.abs(c) + 0.055) / 1.055) ** 2.4);
const toGamma = (c) => Math.sign(c) * (Math.abs(c) <= 0.0031308 ? 12.92 * Math.abs(c) : 1.055 * Math.abs(c) ** (1 / 2.4) - 0.055);
const SRGB_TO_XYZ = [[0.4123908, 0.3575843, 0.1804808], [0.2126390, 0.7151687, 0.0721923], [0.0193308, 0.1191948, 0.9505322]];
const XYZ_TO_SRGB = [[3.2409699, -1.5373832, -0.4986108], [-0.9692436, 1.8759675, 0.0415551], [0.0556301, -0.2039770, 1.0569715]];
const P3_TO_XYZ = [[0.4865709, 0.2656677, 0.1982173], [0.2289746, 0.6917385, 0.0792869], [0, 0.0451134, 1.0439444]];
const XYZ_TO_P3 = [[2.4934969, -0.9313836, -0.4027108], [-0.8294890, 1.7626641, 0.0236247], [0.0358458, -0.0761724, 0.9568845]];
const XYZ_TO_LMS = [[0.8190224, 0.3619063, -0.1288738], [0.0329837, 0.9292869, 0.0361447], [0.0481772, 0.2642395, 0.6335478]];
const LMS_TO_OKLAB = [[0.2104543, 0.7936178, -0.0040720], [1.9779985, -2.4285922, 0.4505937], [0.0259040, 0.7827717, -0.8086758]];
const OKLAB_TO_LMS = [[1, 0.3963378, 0.2158038], [1, -0.1055613, -0.0638542], [1, -0.0894842, -1.2914855]];
const LMS_TO_XYZ = [[1.2268799, -0.5578150, 0.2813910], [-0.0405757, 1.1122868, -0.0717111], [-0.0763729, -0.4214933, 1.5869240]];

const oklchToXyz = ([l, c, h]) => {
  const rad = (h * Math.PI) / 180;
  return mul(LMS_TO_XYZ, mul(OKLAB_TO_LMS, [l, c * Math.cos(rad), c * Math.sin(rad)]).map((x) => x ** 3));
};
const xyzToOklch = (xyz) => {
  const [l, a, b] = mul(LMS_TO_OKLAB, mul(XYZ_TO_LMS, xyz).map(Math.cbrt));
  return [l, Math.hypot(a, b), ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360];
};
const fromXyz = (matrix, xyz) => mul(matrix, xyz).map(toGamma);
const inside = (rgb) => rgb.every((x) => x >= -0.0001 && x <= 1.0001);
const hex = (rgb) => '#' + rgb.map((x) => Math.round(x * 255).toString(16).padStart(2, '0')).join('');

const colours = [
  { css: 'oklch(0.55 0.1 262)', xyz: oklchToXyz([0.55, 0.1, 262]) },
  { css: 'oklch(0.723 0.219 149.579)', xyz: oklchToXyz([0.723, 0.219, 149.579]) },
  { css: 'color(display-p3 0 0.75 0.4)', xyz: mul(P3_TO_XYZ, [0, 0.75, 0.4].map(toLinear)) },
  { css: 'oklch(0.65 0.3 30)', xyz: oklchToXyz([0.65, 0.3, 30]) },
  { css: '#0000ff', xyz: mul(SRGB_TO_XYZ, [0, 0, 1].map(toLinear)) },
  { css: 'color(display-p3 0 1 0)', xyz: mul(P3_TO_XYZ, [0, 1, 0].map(toLinear)) },
];

for (const { css, xyz } of colours) {
  const srgb = fromXyz(XYZ_TO_SRGB, xyz);
  const clipped = srgb.map((x) => Math.min(1, Math.max(0, x)));
  const [l, c, h] = xyzToOklch(xyz);
  const [l2, c2, h2] = xyzToOklch(mul(SRGB_TO_XYZ, clipped.map(toLinear)));
  console.log(css);
  console.log(`  in sRGB: ${inside(srgb) ? 'yes' : 'no'}, in Display P3: ${inside(fromXyz(XYZ_TO_P3, xyz)) ? 'yes' : 'no'}`);
  console.log(`  oklch ${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)} -> on an sRGB screen ${hex(clipped)}, oklch ${l2.toFixed(3)} ${c2.toFixed(3)} ${h2.toFixed(1)}`);
}

Output

oklch(0.55 0.1 262)
  in sRGB: yes, in Display P3: yes
  oklch 0.550 0.100 262.0 -> on an sRGB screen #5171ac, oklch 0.550 0.100 262.0
oklch(0.723 0.219 149.579)
  in sRGB: no, in Display P3: yes
  oklch 0.723 0.219 149.6 -> on an sRGB screen #00c950, oklch 0.728 0.211 147.9
color(display-p3 0 0.75 0.4)
  in sRGB: no, in Display P3: yes
  oklch 0.693 0.230 156.5 -> on an sRGB screen #00c35b, oklch 0.714 0.196 150.2
oklch(0.65 0.3 30)
  in sRGB: no, in Display P3: no
  oklch 0.650 0.300 30.0 -> on an sRGB screen #ff0000, oklch 0.628 0.258 29.2
#0000ff
  in sRGB: yes, in Display P3: yes
  oklch 0.452 0.313 264.1 -> on an sRGB screen #0000ff, oklch 0.452 0.313 264.1
color(display-p3 0 1 0)
  in sRGB: no, in Display P3: yes
  oklch 0.849 0.369 145.6 -> on an sRGB screen #00ff00, oklch 0.866 0.295 142.5

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node gamut_check.mjs

The muted blue fits everywhere. The green oklch(0.723 0.219 149.579), Tailwind CSS 4’s green-500, and the P3 green fit Display P3 but not sRGB, and oklch(0.65 0.3 30) fits neither. The last two rows measure the extremes: pure sRGB blue, and pure P3 green, which an sRGB screen can only show as #00ff00.

What happens to such colours on an sRGB screen is not obvious from the specification, so this page paints the first four as boxes:

Colours outside sRGB, painted on an sRGB screen HTML · clipping.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Colours outside sRGB, painted on an sRGB screen</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    .box { inline-size: 12rem; block-size: 3rem; margin-block-end: 8px; }
    #inside  { background-color: oklch(0.55 0.1 262); }
    #green   { background-color: oklch(0.723 0.219 149.579); }
    #p3      { background-color: color(display-p3 0 0.75 0.4); }
    #beyond  { background-color: oklch(0.65 0.3 30); }
  </style>
</head>
<body>
  <!-- Take a screenshot on an sRGB screen and read the pixels of each box: that is what the engine painted. -->
  <div class="box" id="inside"></div>
  <div class="box" id="green"></div>
  <div class="box" id="p3"></div>
  <div class="box" id="beyond"></div>
  <script>
    for (const box of document.querySelectorAll('.box')) {
      console.log(`${box.id.padEnd(6)} computed ${getComputedStyle(box).backgroundColor}`);
    }
  </script>
</body>
</html>

In screenshots of it taken on an sRGB surface, Chromium 153, Firefox 155 and WebKit 26.6 painted exactly the values in the program’s last column: #5171ac for the muted blue, as computed, then #00c950, #00c35b and #ff0000. All three simply clipped each channel to the range 0 to 1.

Clipping is cheap but not gentle. The P3 green became lighter (0.693 to 0.714), less colourful (0.230 to 0.196) and moved 6 degrees of hue towards yellow. CSS Color 4 describes a better gamut mapping: keep the lightness and the hue, and reduce the chroma in OKLCH until the colour fits. The engines in this run did not do it, and a real P3 screen would show the colour unchanged, so the only way to control what sRGB screens get is to choose their colour yourself.

Flow chart: inside sRGB a colour shows as written; outside it, P3 screens show it and sRGB screens clip it unless you give a fallback.A colour writtenas oklch() orcolor(display-p3 …)Inside sRGB?Every screenshows itas writtenDoes the screencover Display P3?Shown as written(when it isinside P3)Each channelclipped to 0–1:lightness, chromaand hue shiftBetter:@media (color-gamut: p3)gives sRGB screensa colour you choseyesnoyesnoto avoid this

What happens to a colour outside sRGB, and where a fallback fits

Text description of the diagram

The chart starts from a colour written in oklch() or color(display-p3 …) and asks two questions.

  1. Is the colour inside sRGB? If yes, every screen shows it as written, and nothing more is needed.
  2. If not, does the screen cover Display P3? If yes, the screen shows the colour as written, as long as the colour is inside P3.
  3. If the screen covers only sRGB, the colour has to be brought inside sRGB. In the lesson's screenshots, Chromium 153, Firefox 155 and WebKit 26.6 all clipped each red, green and blue channel to the range 0 to 1, which changes the colour's lightness, chroma and hue a little.
  4. To avoid that, use @media (color-gamut: p3) to give sRGB screens a colour you chose yourself and P3 screens the wide-gamut one.

A P3 accent with an sRGB fallback

Two patterns help, and they solve different problems:

A wide-gamut accent with an sRGB fallback HTML · p3-accent.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A wide-gamut accent with an sRGB fallback</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }

    /* Fallback first: a browser that can't read the second value keeps the first. */
    .tag {
      color: #1d3b8f;
      color: oklch(0.4 0.15 264);
    }

    /* A token chosen per screen: sRGB by default, the P3 colour where the screen can show it. */
    :root { --accent: #00b65a; }
    @media (color-gamut: p3) {
      :root { --accent: color(display-p3 0 0.75 0.4); }
    }
    .buy {
      padding: 8px 16px;
      font: inherit;
      color: #062b12;
      background-color: var(--accent);
      border: 0;
      border-radius: 6px;
    }
  </style>
</head>
<body>
  <p class="tag">New arrivals</p>
  <button class="buy" type="button">Buy now</button>
  <script>
    const p3 = matchMedia('(color-gamut: p3)').matches;
    console.log(`color-gamut: ${p3 ? 'p3' : matchMedia('(color-gamut: srgb)').matches ? 'srgb' : 'narrower than srgb'}`);
    console.log(`tag colour: ${getComputedStyle(document.querySelector('.tag')).color}`);
    console.log(`button background: ${getComputedStyle(document.querySelector('.buy')).backgroundColor}`);
  </script>
</body>
</html>
  • A fallback declaration first. color: #1d3b8f; color: oklch(0.4 0.15 264); works because a browser drops a declaration it can’t read and keeps the earlier one. Since oklch() and color() are Baseline widely available, this now protects only very old browsers, and it does nothing for sRGB screens, which can read the value but can’t show it.
  • A token chosen by color-gamut. The color-gamut media feature is srgb, p3 or rec2020, matching when the browser and the screen can show roughly that range; it is Baseline widely available. Set the sRGB colour as the default and override it inside @media (color-gamut: p3), and every screen gets a colour that was chosen for it.

The headless engines of this run all reported an sRGB screen, so all three used the default: the button’s background computed to rgb(0, 182, 90), and the tag to oklch(0.4 0.15 264). Check contrast for both values of the token, because some readers see one and some the other: the dark label #062b12 reaches 5.76:1 on the sRGB green and 6.20:1 on the P3 green. White text would fail on both, at about 2.7:1 and 2.5:1.

@supports (color: oklch(0 0 0)) answers a different question: whether the browser understands the syntax, never what the screen can show. Use it only when a whole rule should depend on support.

Tailwind Color Palette Generator (50–950) Turn one brand colour into a 50 to 950 scale in OKLCH and check the contrast of each step.

Practise: a scale every screen can show

Write a function that builds the nine steps of a brand colour, lowering the chroma only where a step would leave sRGB:

Exercise · Medium · JavaScript

Build a nine-step OKLCH scale that every screen can show

A design system needs nine shades of its brand colour, from a pale background to a dark text colour, that look evenly spaced and that every screen shows as written. Write makeScale({ hue, chroma }) in scale.mjs and export it. It returns an array of nine oklch() strings, lightest first:

- the lightness goes from 0.95 down to 0.31 in equal steps of 0.08, written with two decimals: 0.95, 0.87, …, 0.31; - every step keeps the given hue, written as it was given; - every step uses the given chroma, unless that would put the step outside sRGB. Then it uses the largest chroma with three decimals that keeps it inside, so a pale or a very dark step gets a little less colour instead of being clipped by the browser; - the chroma is written with three decimals: oklch(0.95 0.022 262).

scale.mjs already has a working inSrgb(l, c, h), which converts oklch(l c h) to sRGB with the matrices of CSS Color 4 and says whether every channel is between 0 and 1. Use it; you don't need to change it.

The sample tests import makeScale from scale.mjs and run in your browser. They check the format, the lightness steps, that every step is inside sRGB, that no chroma is lower than it has to be, and that the four lightest steps reach a contrast of 4.5:1 with the darkest, so they can carry dark text and the darkest can be text on them.

Starter code · scale.mjs

// Provided: whether oklch(l c h) is inside sRGB (CSS Color 4: Oklab to XYZ to linear sRGB, then gamma). Keep it.
const mul = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const OKLAB_TO_LMS = [[1, 0.3963378, 0.2158038], [1, -0.1055613, -0.0638542], [1, -0.0894842, -1.2914855]];
const LMS_TO_XYZ = [[1.2268799, -0.557815, 0.281391], [-0.0405757, 1.1122868, -0.0717111], [-0.0763729, -0.4214933, 1.586924]];
const XYZ_TO_SRGB = [[3.2409699, -1.5373832, -0.4986108], [-0.9692436, 1.8759675, 0.0415551], [0.0556301, -0.203977, 1.0569715]];

export function inSrgb(l, c, h) {
  const rad = (h * Math.PI) / 180;
  const lms = mul(OKLAB_TO_LMS, [l, c * Math.cos(rad), c * Math.sin(rad)]).map((x) => x ** 3);
  return mul(XYZ_TO_SRGB, mul(LMS_TO_XYZ, lms)).every((x) => x >= -0.00001 && x <= 1.00001);
}

/** Nine oklch() strings, lightest first, for one hue and a target chroma. */
export function makeScale({ hue, chroma }) {
  // Replace this line with your code.
  return [];
}
The sample tests · scale.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { makeScale } from './scale.mjs';

// The tests' own copy of the conversion (CSS Color 4), so they do not depend on the code under test.
const mul = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const OKLAB_TO_LMS = [[1, 0.3963378, 0.2158038], [1, -0.1055613, -0.0638542], [1, -0.0894842, -1.2914855]];
const LMS_TO_XYZ = [[1.2268799, -0.557815, 0.281391], [-0.0405757, 1.1122868, -0.0717111], [-0.0763729, -0.4214933, 1.586924]];
const XYZ_TO_LIN = [[3.2409699, -1.5373832, -0.4986108], [-0.9692436, 1.8759675, 0.0415551], [0.0556301, -0.203977, 1.0569715]];
const linear = (l, c, h) => {
  const rad = (h * Math.PI) / 180;
  return mul(XYZ_TO_LIN, mul(LMS_TO_XYZ, mul(OKLAB_TO_LMS, [l, c * Math.cos(rad), c * Math.sin(rad)]).map((x) => x ** 3)));
};
const inside = (l, c, h) => linear(l, c, h).every((x) => x >= -0.00001 && x <= 1.00001);
const luminance = (l, c, h) => {
  const [r, g, b] = linear(l, c, h).map((x) => Math.min(1, Math.max(0, x)));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const FORMAT = /^oklch\((\d\.\d\d) (\d\.\d\d\d) (\d+(?:\.\d+)?)\)$/;
const parse = (text) => {
  const m = FORMAT.exec(text);
  assert.ok(m, `"${text}" is not written as oklch(L C H) with two and three decimals`);
  return [Number(m[1]), Number(m[2]), Number(m[3])];
};
const LIGHTNESS = [0.95, 0.87, 0.79, 0.71, 0.63, 0.55, 0.47, 0.39, 0.31];
/** makeScale's result, which must hold nine steps before anything else is checked. */
const nine = (options) => {
  const scale = makeScale(options);
  assert.ok(Array.isArray(scale) && scale.length === 9, `makeScale(${JSON.stringify(options)}) returned ${JSON.stringify(scale)}, not nine steps`);
  return scale;
};

test('nine oklch() strings with the lightness falling in steps of 0.08', () => {
  assert.deepEqual(nine({ hue: 262, chroma: 0.12 }).map((s) => parse(s)[0]), LIGHTNESS);
});

test('the hue stays as given on every step', () => {
  for (const hue of [262, 150, 30.5]) {
    for (const s of nine({ hue, chroma: 0.1 })) assert.equal(parse(s)[2], hue, s);
  }
});

test('a chroma that fits everywhere is kept on every step', () => {
  assert.deepEqual(nine({ hue: 262, chroma: 0.02 }).map((s) => parse(s)[1]), Array(9).fill(0.02));
});

test('every step of a strong colour is inside sRGB', () => {
  for (const [hue, chroma] of [[262, 0.15], [150, 0.15], [90, 0.12], [30, 0.2]]) {
    for (const s of nine({ hue, chroma })) assert.ok(inside(...parse(s)), `${s} is outside sRGB`);
  }
});

test('no chroma is lower than it has to be', () => {
  for (const [hue, chroma] of [[262, 0.15], [90, 0.12]]) {
    for (const s of nine({ hue, chroma })) {
      const [l, c, h] = parse(s);
      assert.ok(c <= chroma, `${s} has more chroma than ${chroma}`);
      if (c < chroma) assert.ok(!inside(l, Math.round((c + 0.001) * 1000) / 1000, h), `${s}: ${(c + 0.001).toFixed(3)} would still fit`);
    }
  }
});

test('the four lightest steps reach 4.5:1 against the darkest', () => {
  const steps = nine({ hue: 262, chroma: 0.15 }).map(parse);
  const dark = luminance(...steps[8]);
  for (const step of steps.slice(0, 4)) {
    const ratio = (luminance(...step) + 0.05) / (dark + 0.05);
    assert.ok(ratio >= 4.5, `oklch(${step.join(' ')}) against the darkest step: ${ratio.toFixed(2)}`);
  }
});
A hint

Loop over the nine steps with 0.95 - i * 0.08, rounded to two decimals so that 0.31 is exactly 0.31. For each step, start from the given chroma and, while inSrgb(l, c, hue) is false, take 0.001 away (round to three decimals each time, or the steps will drift). Then build the string with toFixed(2) and toFixed(3).

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 What does the second number of oklch(0.55 0.15 262) describe?

    Choose one answer.

    Show the answer to question 1

    Answer: How colourful it is (chroma), from 0 for a gray upwards

    OKLCH lists lightness, chroma and hue in that order. Chroma 0 is a gray; the most colourful sRGB colours reach about 0.32, and Display P3's green about 0.37. An alpha would follow a slash, as in oklch(0.55 0.15 262 / 50%).

  2. Question 2 of 6 In the lesson's run of scales.mjs, at which OKLCH lightness did white text first reach 4.5:1 for all three hues?

    Choose one answer.

    Show the answer to question 2

    Answer: 0.55, for blue, green and yellow alike

    OKLCH lightness tracks how light a colour looks, so the step where white text becomes readable is nearly the same for every hue: the ratios at 0.55 were 4.89, 4.63 and 4.87, and at 0.63 all three were below 3.6. In HSL the same switch happened at 50 % for blue and only at 20 % for green and yellow.

  3. Question 3 of 6 A page sets background-color: color(display-p3 0 0.75 0.4) and no fallback. What did all three engines paint on an sRGB surface in the lesson's screenshots?

    Choose one answer.

    Show the answer to question 3

    Answer: #00c35b: each channel clipped to the range 0 to 1, so the colour got lighter and its hue moved

    The colour is valid, so it is used, but sRGB can't show it: its red channel would be negative. Chromium 153, Firefox 155 and WebKit 26.6 all clipped the channels, giving #00c35b, whose OKLCH lightness rose from 0.693 to 0.714 and whose hue moved from 156.5 to 150.2 degrees. CSS Color 4 describes a gentler mapping in OKLCH, but the engines did not use it here.

  4. Question 4 of 6 On an ordinary sRGB laptop, what colour is the button with this CSS?

    Read the code, then choose one answer.

    :root { --accent: #00b65a; }
    @media (color-gamut: p3) {
      :root { --accent: color(display-p3 0 0.75 0.4); }
    }
    .buy { background-color: var(--accent); }
    Show the answer to question 4

    Answer: #00b65a, the colour chosen for sRGB screens

    color-gamut: p3 matches only when the browser and the screen can show roughly the P3 range, so on an sRGB laptop the rule inside it doesn't apply and the token keeps #00b65a. All three engines in the lesson's run reported rgb(0, 182, 90) for the button.

  5. Question 5 of 6 An element has color: oklch(0.4 0.15 264). What does getComputedStyle(element).color return in current browsers?

    Choose one answer.

    Show the answer to question 5

    Answer: oklch(0.4 0.15 264)

    Unlike hex, rgb(), hsl() and hwb(), which are reported as rgb(), an oklch() colour keeps its own form, with the lightness as a number between 0 and 1, because converting it to sRGB could lose colours outside sRGB. All three engines in the lesson's run reported oklch(0.4 0.15 264).

  6. Question 6 of 6 Why does color: #1d3b8f; color: oklch(0.4 0.15 264); put the hex colour first?

    Choose one answer.

    Show the answer to question 6

    Answer: A browser that can't read oklch() drops the second declaration and keeps the first

    Later declarations win only when they are valid; an invalid one is dropped as if it were not there. Writing the older value first gives old browsers something to use. Since oklch() is Baseline widely available, this guards only very old browsers; for sRGB screens, the color-gamut query is the tool.

Interview questions

Fresher · What do the three numbers in oklch(0.55 0.15 262) mean, and why use OKLCH instead of HSL for a palette?

The first number is the lightness, from 0 for black to 1 for white; the second is the chroma, how colourful the colour is, from 0 for a gray to about 0.32 for the most vivid sRGB colours; the third is the hue in degrees, where 262 is a blue. OKLCH is built on Oklab, which was designed so that equal steps in its numbers look like equal steps to the eye. HSL’s lightness is only the average of the largest and smallest channel, so the same HSL lightness gives yellow 13 times the luminance of blue. In OKLCH, one lightness gives nearly the same contrast for every hue: white text reaches 4.5:1 at about 0.55 whatever the hue, so a palette can use one rule for all its colours.

Key takeaways

  • oklch(L C H) is lightness from 0 to 1, chroma from 0 to about 0.37, and hue in degrees; oklab() is the same space with a and b axes.
  • Equal OKLCH lightness gives nearly equal contrast across hues: white text works at 0.55 or lower, dark text at 0.63 or higher.
  • Lower the chroma at the light and dark ends of a scale, or those steps fall outside sRGB.
  • On an sRGB surface, Chromium, Firefox and WebKit all clipped colours outside sRGB channel by channel, shifting their lightness and hue.
  • Give sRGB screens a colour you chose with @media (color-gamut: p3), and check contrast for both values of the token.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.