Color Conversion and WCAG Contrast, Explained
After reading this you will be able to convert a color between HEX, RGB, HSL and OKLCH by hand, and compute the WCAG contrast ratio between any two colors to decide whether text will be readable.
A color on the web is three numbers plus, sometimes, a fourth for opacity. The three numbers are the intensities of red, green and blue light your screen emits. Everything else (HEX, HSL, OKLCH) is a different way of writing or reshaping those same three numbers so humans can reason about them.
Here is the hook. The color #3b82f6 is a mid blue. In RGB it is rgb(59, 130, 246). Put white text on it and the contrast ratio is 3.68:1. That passes WCAG AA for large text (needs 3:1) but fails AA for normal body text (needs 4.5:1). The same swatch is fine for a heading and wrong for a paragraph. This article shows you why, and how to check it in seconds.
What the four formats actually store
All four formats used by the tool describe the same visible colors, but they organize the information differently.
- HEX
- Six hexadecimal digits, two per channel:
#RRGGBB. Each pair is a byte from00toff, meaning 0 to 255.#3b82f6is red3b= 59, green82= 130, bluef6= 246. - RGB
- The same three 0 to 255 values written in decimal. No new information, just a different base.
- HSL
- Hue (0 to 360 degrees), saturation (0 to 100%) and lightness (0 to 100%). A cylindrical rearrangement of RGB. Easy to spin the hue, but its lightness does not match how bright a color looks.
- OKLCH
- Lightness (0 to 1), chroma (roughly 0 to 0.4) and hue (0 to 360 degrees) in a perceptually uniform space. Equal steps in
Llook like equal steps in brightness to your eye.
HEX and RGB are lossless siblings: converting one to the other and back changes nothing. HSL is a fixed geometric transform of RGB, also reversible. OKLCH is different in kind. It is built on measurements of human vision, so it corrects HSL's biggest flaw, which the next section shows.
Why OKLCH looks even and HSL does not
In HSL, yellow at 50% lightness (hsl(60 100% 50%)) is a bright, glowing yellow, while blue at the same 50% (hsl(240 100% 50%)) is a dark, murky blue. The number says "same lightness" but your eye disagrees. That happens because HSL's lightness is a simple average of the largest and smallest RGB channels, which ignores that your eye is far more sensitive to green than to blue.
OKLCH fixes this by weighting channels the way the retina does. If you build a row of swatches at fixed L = 0.7 and sweep the hue from 0 to 360, every swatch reads as roughly the same brightness. Do the same in HSL and the yellows leap forward while the blues sink back.
When you generate a palette or a set of tints, step the OKLCH lightness in equal amounts (for example L = 0.4, 0.5, 0.6, 0.7, 0.8). The steps will look even. Doing the same with HSL lightness gives visibly uneven jumps.
The contrast ratio formula
WCAG contrast is not a difference of colors. It is a ratio of luminances. Luminance is how much light a color emits, on a scale from 0 (black) to 1 (white). The ratio compares the lighter color to the darker one.
Here L_1 is the relative luminance of the lighter color and L_2 is that of the darker color. The 0.05 added to each is a flare term that models ambient light reflecting off a real screen, which keeps the ratio finite. The lowest possible value is 1:1 (two identical colors) and the highest is 21:1 (pure black on pure white).
Relative luminance itself comes from the linearized RGB channels:
The weights sum to 1 and reflect eye sensitivity: green counts for 72%, red for 21%, blue for only 7%. But R, G and B here are not the raw 0 to 255 values. Each channel is first divided by 255, then "linearized" to undo the gamma curve baked into sRGB:
In this piecewise rule c is a channel value already scaled to 0 to 1. Dark channels use the straight line divide by 12.92; brighter channels use the power curve. This matches how sRGB stores values, so the luminance you get corresponds to actual emitted light.
Worked example: white text on the default blue
Reproducing the demo: #ffffff on #3b82f6
The tool's defaults put white foreground text on the blue background #3b82f6. Follow the arithmetic.
- Background channels scaled to 0 to 1:
R = 59/255 = 0.2314,G = 130/255 = 0.5098,B = 246/255 = 0.9647. - Linearize each (all are above 0.03928, so use the power curve).
R= (0.2314+0.055)/1.055)^{2.4} = 0.04519,G=0.2232,B=0.9215. - Luminance of the blue: 0.2126(0.04519) + 0.7152(0.2232) + 0.0722(0.9215) = 0.2360.
- White is
#ffffff, so its luminance is exactly1. - White is lighter, so L_1 = 1 and L_2 = 0.2360.
- Ratio: C = (1 + 0.05) / (0.2360 + 0.05) = 1.05 / 0.2860 = 3.671.
The result is 3.67:1. That clears AA large text (3:1) and AA UI components (3:1), but misses AA normal text (4.5:1). For body copy on this blue, either darken the blue or switch the text to a darker color.
Reading the pass and fail verdicts
WCAG 2.1 sets four thresholds. Two levels (AA and AAA) each split by text size.
| Text | AA | AAA |
|---|---|---|
| Normal (under 18pt, or under 14pt bold) | 4.5:1 | 7:1 |
| Large (18pt+, or 14pt+ bold) | 3:1 | 4.5:1 |
| UI components and graphics | 3:1 | n/a |
"Large" means about 24px for normal weight or 18.66px for bold, since 18pt equals 24px at the default 96dpi. A ratio of 3.67:1 lands between the 3:1 and 4.5:1 lines, which is exactly why it passes for headings but fails for paragraphs.
When to convert, and when not to
Reach for OKLCH when you are designing a system: tints, shades, hover states, or a hue that must stay equally bright as you rotate it. Reach for HSL when you want a quick, familiar hue spin and do not care about perceptual evenness. Stay in HEX or RGB when you are matching an exact brand value or pasting into code that expects them.
Do not convert just to convert. Every trip through HSL rounds to whole percentages, and every trip through OKLCH and back to sRGB can shift a channel by 1 in 255 because of rounding in the color-space math. One conversion is safe. Converting the same value back and forth ten times can drift it visibly.
OKLCH can describe colors outside the sRGB gamut, for example oklch(0.7 0.37 140). When you convert such a value to HEX, it gets clamped into range and the result will not match what OKLCH promised. Check that the chroma is reachable before trusting the HEX output.
Common mistakes
Three errors show up again and again.
- Comparing raw brightness instead of luminance. Averaging RGB values or eyeballing HSL lightness does not predict contrast. Only the gamma-corrected, green-weighted luminance does. That is why a "medium" gray on white can still fail.
- Forgetting the alpha channel. Semi-transparent text sits on whatever is behind it. The real contrast depends on the blended color, not the nominal one. Flatten the color against its actual backdrop before checking.
- Testing only one pairing. A button might pass in its rest state and fail on hover, or pass in light mode and fail in dark mode. Check every state you ship.
Related tools
Once your colors pass, the CSS Gradient, Shadow & Radius Generator helps you build gradients and shadows from the same values. If you are auditing a page's markup alongside its colors, the HTML Entity Reference and the URL Parser & Query Editor cover other parts of the front end.
Frequently asked questions
Why does my color pass contrast in one checker and fail in another?
Almost always rounding or alpha handling. If one tool flattens a semi-transparent color and another uses the nominal value, the ratios differ. The formula itself is fixed by WCAG, so on fully opaque colors any correct tool agrees to two decimals.
Is a higher contrast ratio always better?
For readability, higher is safer, up to the 21:1 maximum of black on white. But very high contrast can cause visual strain for some readers with dyslexia or light sensitivity. Meeting AA (4.5:1) or AAA (7:1) is the target, not the absolute maximum.
What counts as large text?
18pt or larger for normal weight, or 14pt or larger if bold. At the default 96dpi that is about 24px normal and 18.66px bold. Large text only needs 3:1 for AA and 4.5:1 for AAA.
Does OKLCH give better contrast than HEX?
No. Contrast depends only on the final color, not on how you wrote it. OKLCH makes it easier to pick a color that will pass, because lowering L predictably raises contrast against white, but the ratio is identical once the color resolves to the same sRGB pixel.
Is the contrast ratio symmetric?
Yes. Swapping foreground and background gives the same ratio, because the formula always puts the lighter luminance on top. White on blue and blue on white both read 3.67:1.