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.
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
#0000ffatoklch(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
#0000ffsits 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.
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
<!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.
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.
- Is the colour inside sRGB? If yes, every screen shows it as written, and nothing more is needed.
- 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.
- 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.
- 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:
<!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. Sinceoklch()andcolor()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. Thecolor-gamutmedia feature issrgb,p3orrec2020, 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.
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).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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 withaandbaxes.- 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
- CSS Color Module Level 4: Oklab and OkLCh (W3C CSS Working Group)
- CSS Color Module Level 4: specifying Oklab and OkLCh colours (W3C CSS Working Group)
- CSS Color Module Level 4: predefined colour spaces and color() (W3C CSS Working Group)
- CSS Color Module Level 4: gamut mapping (W3C CSS Working Group)
- CSS Color Module Level 4: sample code for colour conversions (W3C CSS Working Group)
- Media Queries Level 4: the color-gamut feature (W3C CSS Working Group)
- A perceptual color space for image processing (Oklab) (Björn Ottosson)
- oklch() (MDN Web Docs) (Mozilla)
- color() (MDN Web Docs) (Mozilla)
- Oklab and OkLCh (web-features explorer) (W3C WebDX Community Group)
- Colors (Tailwind CSS documentation) (Tailwind Labs)
- Web Content Accessibility Guidelines (WCAG) 2.2: Contrast (Minimum) (W3C)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress