Blog · October 7, 2026

HSL’s lightness slider doesn’t measure lightness.

Every colour picker with an HSL mode makes the same quiet promise: the L slider is lightness, so two colours with the same L should look equally light. Pure yellow and pure blue both sit at exactly 50%. One is a highlighter and the other is royal blue, and white text gets a contrast ratio of 1.07:1 on the first and 8.59:1 on the second. Nothing is miscalibrated. The L in HSL was never a measurement of how light a colour looks, and any code that does arithmetic on it inherits the error.

What the L actually computes

HSL lightness is the average of a colour’s largest and smallest RGB channel: (max + min) / 2. That’s the whole formula. A fully saturated colour always has one channel at full and one at zero, so every pure hue on the wheel lands at exactly 50%, whatever it looks like. HSL is the RGB cube rearranged into a cylinder so that a colour picker could offer knobs people understand. It was never fitted to anyone’s eyes.

Eyes weight the three channels very differently. In linear light, the luminance of an sRGB colour is 0.2126 × red + 0.7152 × green + 0.0722 × blue, the same coefficients the WCAG contrast formula uses. Green counts almost ten times as much as blue. HSL counts them equally, so it is wrong in a predictable direction: it overrates blues and violets and underrates yellows, greens and cyans.

Here is every primary and secondary hue at exactly 50% HSL lightness, each with white text set on it, next to two numbers that do follow the eye: the contrast ratio of that white text, and the lightness channel of OKLCH, which comes up again below.

White on hueContrastOKLCH L
Yellow1.07:10.968
Cyan1.25:10.905
Green1.37:10.866
Orange2.52:10.732
Magenta3.14:10.702
Red4.00:10.628
Blue8.59:10.452

Seven colours share one lightness value. Their relative luminance runs from 0.072 for blue to 0.928 for yellow, a factor of almost thirteen.

Where it quietly breaks your interface

None of this matters while you pick colours by eye. It starts to matter the moment code does arithmetic on L, which most codebases do somewhere.

Hover and pressed states. Sass’s lighten(), Flutter’s HSLColor.withLightness() and most hand-rolled shade helpers add or subtract a fixed amount of L. Lighten pure blue by ten points and its OKLCH lightness moves from 0.452 to 0.497, a step you can see. Lighten pure yellow by the same ten points and it moves from 0.968 to 0.969. The yellow button’s hover state exists in the stylesheet and nowhere on the screen.

Readability checks. Code that picks black or white text with if (hsl.lightness > 0.5) puts white text on pure yellow and pure green, because both sit at exactly 0.5. That’s 1.07:1 and 1.37:1, against the 4.5:1 WCAG asks for body text. The same mistake hides in helpers that ask “is this accent visible on this background?” by comparing L values. Yellow on white is 50 points apart in HSL, which clears any threshold you’d set, and measures 1.07:1. Blue on black is also 50 points apart and comes out at 2.44:1, under even the 3:1 bar for large text.

Swapping a theme colour. If every hue’s “500” step was made by setting L to 50%, the steps aren’t interchangeable. A blue-500 header with white type reads fine; change the accent to yellow-500 and the same layout becomes unreadable, with nothing in the token names to warn you.

Colour spaces built around the eye

Perceptual colour spaces exist to make that L honest. CIELAB, standardised by the CIE in 1976, defines L*, a lightness scale from 0 to 100 fitted to how people judge light and dark. Its weak spot is hue: blue blended toward white drifts purple. In December 2020 Björn Ottosson published Oklab, which keeps the perceptual lightness and fixes that drift. OKLCH is its cylindrical form, with the same three knobs as HSL (lightness, chroma, hue), except that equal L now means roughly equal lightness to a person looking at it. That’s the column in the table above, and it says what HSL hid: blue at 0.45, yellow at 0.97.

Google built Material You’s dynamic colour on its own variant, HCT: hue and chroma from the CAM16 colour appearance model, and a tone channel that is CIELAB’s L*. The payoff is that contrast becomes subtraction. The Material Color Utilities source says a tone difference of 40 guarantees a contrast ratio of at least 3:1, and 50 guarantees at least 4.5:1. I checked the arithmetic. The 40 rule holds everywhere; its worst pair, tones 60 and 100, still gives 3.17:1. The 50 rule bottoms out at 4.48:1, for tones 50 and 100. Use it as a design rule, then run the real contrast check before you ship.

The catch: equal lightness gives you unequal colour

Switch to OKLCH, hold L at 0.5 for every hue, and the blues stay vivid while the yellow turns olive. The most saturated yellow an sRGB screen can show at that lightness is #686800. That isn’t a flaw in the model. Yellow is a light colour; a yellow as dark as royal blue stops reading as yellow. Every hue has its own chroma ceiling at every lightness, and a perceptual space simply stops pretending otherwise.

Tailwind’s v4 palette shows how the people who build these ramps for a living deal with it. Every colour is defined in OKLCH, yet yellow-500 sits at 79.5% lightness and blue-500 at 62.3%. The step names line up across hues; the lightness values deliberately don’t. For your own tokens, fix lightness per role where legibility depends on it (body text, surfaces, filled buttons) and let each hue reach whatever chroma it can at that lightness. Ask for more chroma than the screen can show and the browser brings the colour back inside the display’s range; Material’s Hct lowers chroma until it fits.

One more caution: OKLCH lightness is a model of perception, not an accessibility standard. Build with L, and verify with the WCAG contrast ratio, because that’s the number accessibility audits compute.

Switching, platform by platform

On the web, oklch() and color-mix() have worked in every major browser since May 2023. Relative colour syntax, which derives a shade from an existing token, needs Chrome 119, Firefox 128 or Safari 18.

:root { --brand: oklch(0.62 0.21 260); }

.button:hover  { background: oklch(from var(--brand) calc(l + 0.06) c h); }
.button:active { background: color-mix(in oklch, var(--brand), black 15%); }

Flutter ships HSLColor and HSVColor and no perceptual space, but the framework already depends on Google’s material_color_utilities, which is what ColorScheme.fromSeed is built on. Flutter pins an exact version of it, so add it to your own pubspec with the constraint any and you get Hct:

import 'package:material_color_utilities/material_color_utilities.dart';

Color shiftTone(Color c, double delta) {
  final hct = Hct.fromInt(c.toARGB32());
  return Color(Hct.from(hct.hue, hct.chroma, hct.tone + delta).toInt());
}

For the contrast check itself, Color.computeLuminance() already returns the WCAG luminance. In Jetpack Compose, lerp() between two colours interpolates in Oklab, and ColorSpaces.Oklab is there for conversions. In SwiftUI, Color.mix(with:by:in:) (iOS 18) mixes in the .perceptual colour space by default, so brand.mix(with: .black, by: 0.15) is a pressed state that doesn’t do raw RGB arithmetic.

A thirty-second check

Take the hue away and look at what’s left. Put your swatches and one real screen side by side and view them in greyscale: Grayscale under Color Filters in the iOS accessibility settings, Monochromacy under Simulate color space in Android’s developer options, or Achromatopsia in the Rendering panel of Chrome DevTools. Colours that play the same role, like every fill you might put white text on, should come out as similar greys, and every line of text should still stand clear of what’s behind it. Anything that sinks into its background had a lightness problem the hue was covering for.

Where it shows up first: a store screenshot set, where one accent colour sits on several different backgrounds and every frame is seen at thumbnail size in search results. An accent that reads on the dark frames can vanish on the light one, and nobody squints at a thumbnail. Run the greyscale check on the whole set side by side.