Design
HEX, RGB, HSL and OKLCH: choosing color formats and checking contrast
CSS now accepts five ways to write the same color, and the differences are not cosmetic: two of them describe the same sRGB numbers in different clothes, while one changes what the numbers mean. This article explains what each notation models, why HSL lightness misleads and OKLCH lightness mostly does not, what happens when a color leaves the sRGB gamut, and exactly how the WCAG 2 contrast ratio is calculated. The numbers below come from this site's own color module and the contrast checker's formula, so you can reproduce them.
By the MaffeiTools team · Updated · 7 min read
Four notations for one set of sRGB numbers
HEX, rgb(), hsl() and hwb() all resolve to sRGB, the same three gamma-encoded channels with 256 steps each. HEX is those channels written in base 16: #ff6b35 is red 255, green 107, blue 53. The rgb() function is the same triple in decimal. The hsl() and hwb() functions are cylindrical rearrangements of that triple into hue, then saturation and lightness, or hue, then whiteness and blackness. CSS Color Level 4 defines hwb() as a full-chroma hue mixed with white and black, and gives hwb(150 20% 10%) as equal to hsl(150 77.78% 55%). The converter reproduces that pair exactly.
Because they share one underlying space, converting among these four is lossless up to rounding, and none can express a color outside sRGB. The fifth notation is different: oklch() is defined in the Oklab space, which is not tied to a display's primaries and can describe colors sRGB cannot show. The output below is from the color parsing and formatting module behind the OKLCH generator and the color converter.
input #ff6b35 rgb rgb(255 107 53) hsl hsl(16.04 100% 60.39%) oklch oklch(70.45% 0.1926 39.23) p3 color(display-p3 0.9302 0.4557 0.272) input hwb(150 20% 10%) hsl hsl(150 77.78% 55%)
Why HSL lightness is not perceived lightness
In this module HSL lightness is (max + min) / 2 of the gamma-encoded channels. That is arithmetic on signal values, and the eye does not weigh the channels equally: green contributes far more to perceived brightness than blue. The CSS Color 4 specification makes the same point with the same two colors used below, noting that hsl(240deg 100% 50%) blue and hsl(60deg 100% 50%) yellow share a lightness of 50 percent while the yellow clearly looks lighter.
The table shows six fully saturated hues at HSL lightness 50 percent. The OKLCH lightness column comes from this repository's conversion. The specification quotes sRGB blue at 0.452 and yellow at 0.968, and the values here agree with those to the digits shown. The last column is the WCAG contrast ratio against white, calculated by the same formula as the contrast checker.
color hex OKLCH lightness contrast vs white hsl(0 100% 50%) #ff0000 62.8% 4.00 hsl(60 100% 50%) #ffff00 96.8% 1.07 hsl(120 100% 50%) #00ff00 86.64% 1.37 hsl(180 100% 50%) #00ffff 90.54% 1.25 hsl(240 100% 50%) #0000ff 45.2% 8.59 hsl(300 100% 50%) #ff00ff 70.17% 3.14
Worth knowing. This is the practical consequence: a palette built by stepping HSL lightness gives text colors whose contrast changes wildly from hue to hue. The hue axis is uneven too. The specification observes that the 30 degree gap between hsl(220deg 100% 50%) and hsl(250deg 100% 50%) looks small while a 30 degree gap near yellow looks large.
What OKLCH changes: lightness, chroma and hue you can hold constant
Björn Ottosson introduced Oklab in 2020 for tasks such as keeping perceived lightness fixed while changing hue, raising saturation without shifting hue, and producing smooth gradients. OKLCH is the polar form of Oklab: L is lightness from 0 to 1, C is chroma, the distance from gray, and H is a hue angle in degrees. In CSS, a percentage for L maps 100 percent to 1.0, and a percentage for C maps 100 percent to 0.4. Chroma has no fixed ceiling, though the specification says it tops out near 0.5 in practice. When chroma is essentially zero (0.000004 or less) the hue is powerless, meaning it no longer affects the color.
Holding L constant while rotating hue therefore gives colors of similar luminance, which is exactly what HSL fails to do. The table fixes oklch(70% 0.12 H) and varies only the hue. The HSL lightness of the results wanders from about 41 to 71 percent, yet contrast against black stays inside a narrow band. It is close, not identical: OKLCH lightness is a model of perception, and WCAG contrast is a separate formula based on luminance, so they agree only approximately.
color hex HSL lightness vs black vs white oklch(70% 0.12 30) #df8071 65.91% 7.46 2.81 oklch(70% 0.12 90) #bb9b39 47.98% 7.86 2.67 oklch(70% 0.12 150) #63b376 54.54% 8.24 2.55 oklch(70% 0.12 210) #09b2c7 40.89% 8.20 2.56 oklch(70% 0.12 270) #819ae9 71.1% 7.73 2.72 oklch(70% 0.12 330) #c882c2 64.76% 7.43 2.83
Gamut: what happens to oklch(70% 0.3 150)
Because OKLCH can describe colors beyond a given display, a valid oklch() value may have no exact sRGB equivalent. The module reports the narrowest gamut a color fits in and does not silently clamp: sRGB, Display P3, or outside both. For oklch(70% 0.3 150) the unclamped sRGB channels come out with red at about negative 115 and blue at about negative 21, which cannot be encoded, and the color does not fit Display P3 either. Two ways of getting a hex value from it give visibly different answers, and the difference is the reason to choose the mapping method on purpose.
Clipping each channel to 0 through 255 moves the color in every axis at once: read back through the same conversion, #00cb00 is oklch(72.96% 0.2483 142.5), so lightness rose by about three points and the hue slid from 150 to 142.5. Reducing chroma while pinning lightness and hue, the family of approach that CSS Color 4 gamut mapping uses, stays on the requested hue. The generator's own mapping function does that by bisection on chroma. Browsers apply their own CSS gamut mapping when they must display an out-of-gamut color, so what appears on screen may differ from either result below.
gamut out (outside sRGB and Display P3) sRGB channels r -115.43 g 203.19 b -21.40 clipped hex #00cb00 (reads back as oklch(72.96% 0.2483 142.5)) chroma-reduced oklch(70% 0.1928 150) -> #00be58 (reads back as oklch(70.05% 0.1928 150.03)) display-p3 color(display-p3 0 0.7814 0.2008)
The WCAG 2 contrast formula, step by step
WCAG 2.2 defines relative luminance as L = 0.2126 R + 0.7152 G + 0.0722 B, where each channel is first divided by 255 and then linearized: if the value is at most 0.04045, divide by 12.92, otherwise compute ((value + 0.055) / 1.055) raised to 2.4. The threshold used to be written 0.03928. The specification notes it was changed in May 2021 to match the sRGB standard and has no practical effect on results. The contrast ratio is (L1 + 0.05) / (L2 + 0.05), with L1 the lighter color's luminance, so it runs from 1 for identical colors to 21 for black on white.
The thresholds come from success criteria. Level AA text needs 4.5 to 1, or 3 to 1 for large-scale text, defined as at least 18 point, or 14 point bold, which is 24 CSS pixels and roughly 18.67 pixels. Level AAA needs 7 to 1, or 4.5 to 1 for large text. Non-text contrast, such as the boundary of a form control, needs 3 to 1 against adjacent colors. The gray worked below is the classic boundary: #767676 is the lightest pure gray that still passes 4.5 to 1 on white.
#767676 linear channel 0.18116 L = 0.18116 #ffffff L = 1 ratio = (1 + 0.05) / (0.18116 + 0.05) = 4.5422 #777777 L = 0.18447 ratio 4.4781 (fails 4.5) #595959 ratio 7.0047 (passes 7) #949494 ratio 3.0335 (passes 3, fails 4.5) #000000 on #ffffff ratio 21
Worth knowing. The specification's own guidance says computed ratios must not be rounded, so a 4.499 to 1 result does not meet 4.5. The contrast checker follows that: it truncates the ratio to two decimals and judges every threshold on the displayed number, which is why #777777, at 4.478, shows as 4.47 and fails instead of rounding up to a misleading 4.48.
Where the WCAG 2 ratio stops being the whole story
The formula uses luminance only. It does not model hue, stroke thickness, font weight beyond the single large-text switch, or which color is the text and which is the background. Mid-gray shows the effect: #808080 has a ratio of 3.95 against white and 5.32 against black, so by the formula white text on that gray fails AA for body text and black text passes, and the formula itself has no way to say which reads better in practice. Red is another example: pure #ff0000 on white is 4.00, below 4.5, while pure blue is 8.59.
The success criteria also apply to the colors as authored, not to how a particular renderer paints them. The Understanding document for 1.4.3 warns that anti-aliasing can make thin or unusual fonts render fainter than their nominal color, so a pass on paper is no guarantee for a thin typeface. WCAG 3.0 is a W3C Working Draft, most recently published on 10 September 2026, and it has not replaced version 2.x as the standard that audits and policies cite. Until it does, the ratio above is the number that matters.
The contrast checker takes two opaque colors from color pickers and returns a ratio plus pass or fail for four thresholds. It does not handle transparency, gradients or images behind text, and it has no text-size input, so decide whether your text is large-scale yourself and read the matching row.
Decision rules: which notation to write and which to check
Use HEX or rgb() for interchange with design tools, image editors and anything that predates CSS Color 4. Use hsl() for quick edits where you want the hue to stay put and are not relying on lightness steps to keep contrast. Use oklch() when you generate a scale, derive hover states, or need a family of colors with the same weight, and keep a hex fallback if you support older engines. For colors that exceed sRGB, write oklch() or color(display-p3 ...) and confirm what the constrained result looks like with the OKLCH generator's gamut display before shipping.
Whatever notation you write, calculate contrast on the sRGB value that will actually be displayed, since that is what the ratio's linearization assumes. Convert with the color converter, paste the resulting hex into the contrast checker, and check both text sizes you use. If you build a lightness scale, the color shades generator and palette generator produce candidates, and the contrast checker tells you which pairs of steps are usable for text.
Sources
- W3C CSS Color Module Level 4: HSL, HWB, Oklab and OkLCh, gamut mapping
- W3C WCAG 2.2: definitions of relative luminance and contrast ratio, success criteria 1.4.3, 1.4.6 and 1.4.11
- W3C Understanding Success Criterion 1.4.3 Contrast (Minimum)
- Björn Ottosson: A perceptual color space for image processing (Oklab)
- W3C WCAG 3.0 Working Draft