Accessibility · Perception

Computing Relative Luminance and Contrast Ratios from sRGB Values

The linearisation step, the weighted sum behind relative luminance, and why the WCAG ratio adds a constant to both values before dividing them.

A stylesheet value such as #3A7BD5 is not a physical quantity. It is an index into an encoding, and every contrast calculation begins by undoing that encoding. The number that comes out the other end, relative luminance, is a single scalar on a scale from 0 (black) to 1 (white) that approximates how much light a color reflects or emits, weighted by the sensitivity of the three cone classes. Two colors that look wildly different on screen can have nearly identical luminance; two colors that look similar in hue can differ by a factor of ten.

Undoing the transfer function

sRGB stores each channel as an 8-bit value with a transfer function applied. Roughly speaking, the middle of the code range represents a much smaller fraction of light than the middle of the physical range: code 128 is about 21 percent of full intensity, not 50 percent. The transfer function is piecewise. Below a small threshold the curve is linear with a slope of 12.92; above it the curve is a scaled power function with an exponent of 2.4.

The threshold exists because the pure power function has an infinite derivative at zero, which is numerically hostile and does not match measured display behaviour near black. The two segments are joined so that the value and the slope agree at the breakpoint, which is why the coefficients are not round numbers. IEC 61966-2-1 places the breakpoint at an encoded value of 0.04045. The WCAG 2 formula, written before that text was finalised, uses 0.03928.

That discrepancy has no effect on 8-bit input. Code 10 divides to 0.039216, which is below both thresholds; code 11 divides to 0.043137, which is above both. Since no integer code from 0 to 255 falls inside the gap between the two constants, an 8-bit implementation gets the same answer either way. The gap only matters for wider gamuts, 16-bit channels, or colors specified as floating-point values, where a coordinate can land between 0.03928 and 0.04045 and take a different branch depending on which constant the code uses.

The weighted sum and what it means for hue

After linearisation, the three channels are combined with fixed weights: 0.2126 for red, 0.7152 for green, 0.0722 for blue. These are the luminance coefficients of the sRGB primaries, derived from the Rec. 709 primaries and the D65 white point. Green carries almost three quarters of the total, blue carries seven percent.

The practical consequence is that hue is nearly irrelevant to the metric and only brightness survives. Pure blue, #0000FF, has a relative luminance of 0.0722. Against white it produces a contrast ratio of about 8.6:1, comfortably readable. Against black it produces about 2.4:1, which fails even the reduced threshold for large text. Pure red, #FF0000, is the mirror image: a luminance of 0.2126 gives about 5.3:1 against black and about 4.0:1 against white, so the same red passes on one background and fails the 4.5:1 body-text threshold on the other. Any palette built by picking hues at even intervals around a color wheel will have wildly uneven luminance and therefore wildly uneven legibility.

Why both values get a constant added

The ratio is not L1 / L2. It is (L1 + 0.05) / (L2 + 0.05), with the lighter color as numerator. The additive term is a stand-in for ambient reflection off the display surface and for veiling glare in the eye and the display stack. Without it, the ratio between two near-black colors would be enormous and the ratio between two near-white colors would collapse to nearly 1, neither of which matches perception.

The offset also fixes the scale. With black at 0 and white at 1, the maximum possible ratio is 1.05 / 0.05, exactly 21:1, and that is the largest number the formula can return. Some tools report ratios above 21 by computing in a wider space; such a result is not comparable to the thresholds.

Adding the constant compresses the bright end of the scale. Two colors at luminance 0.80 and 0.90 differ by 0.10 on the luminance axis but produce a ratio of only about 1.12:1. Two colors at 0.02 and 0.12 differ by the same 0.10 and produce about 2.4:1. The metric is therefore dominated by the darker color, which is why dark-on-dark palettes fail so much more often than light-on-light ones, and why raising a background from 0.85 to 0.95 buys almost nothing.

Thresholds and the pixel geometry they assume

WCAG sets 4.5:1 for body text and 3:1 for text that qualifies as large: at least 18 point, or at least 14 point when bold. Because a CSS pixel is defined as 1/96 inch and a point as 1/72 inch, those boundaries land on 24 px and on 18.66 px respectively. The relaxation for large text is a proxy for stroke width. A glyph at 24 px has strokes roughly twice as thick as one at 12 px, so the same luminance difference produces a wider band of unambiguous edge, and the eye integrates over a larger area. The formula itself knows nothing about size, weight, or spatial frequency, so the two-tier threshold is the only place that information enters.

The corresponding 3:1 requirement for interface components and graphical objects is a separate rule with its own notion of what counts as the relevant boundary; which edges must be visible covers that judgment.

The colors that actually reach the eye

The ratio must be computed on composited values, not on the declared ones. A color with alpha is blended with whatever is behind it, and browsers performing legacy sRGB compositing do the interpolation on the encoded channel values, not in linear light. The difference is large enough to change verdicts.

Take a 50 percent black overlay on white. Encoding-space blending gives 0.5 × 1.0 = 0.5, which linearises to about 0.214. Linear-light blending gives 0.5 × 1.0 = 0.5 as a linear value, which is a much lighter surface. The encoded result is therefore considerably darker than a linear-light calculation predicts, and a translucent scrim contributes more darkening than naive arithmetic suggests. An author who models the overlay in linear light underestimates the contrast that light text will actually have.

Gradients and images make the problem worse because the ratio is no longer a single number; it varies across the glyph. Text over photographic backdrops treats that as a distribution rather than a value.

def srgb_to_linear(channel):          # channel in 0..1, encoded
    return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4

def relative_luminance(rgb):
    r, g, b = (srgb_to_linear(c / 255) for c in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast_ratio(a, b):
    la, lb = relative_luminance(a), relative_luminance(b)
    hi, lo = max(la, lb), min(la, lb)
    return (hi + 0.05) / (lo + 0.05)

print(f"{contrast_ratio((0, 0, 255), (255, 255, 255)):.2f}")   # 8.59, blue on white
print(f"{contrast_ratio((0, 0, 255), (0, 0, 0)):.2f}")         # 2.44, blue on black

Boundary verdicts and rounding

Because the thresholds are hard cut-offs, colors that sit within a rounding error of 4.5:1 are decided by arithmetic detail rather than by perception. On white, the lightest neutral gray that clears 4.5:1 is 8-bit code 118, #767676, at about 4.54:1. Code 119 drops to roughly 4.48:1. Both are visually indistinguishable in isolation, and the pair is the standard example of a boundary where the pass/fail split is arbitrary but the specification is unambiguous: the comparison is on the computed ratio, so a tool that rounds to one decimal place before comparing will classify 4.48 as a pass and disagree with a tool that does not.

Sub-pixel antialiasing, font smoothing, and weight introduce error in the other direction. A 300-weight serif at 15 px renders with stems that may be a single physical pixel wide, and the edge pixels are blends of the text color and the background. The declared foreground color may meet 4.5:1 while most of the ink that reaches the retina sits at an intermediate value closer to the background. The formula cannot see this, which is one of the arguments behind perceptual contrast models that go beyond it: they trade the simplicity of a two-color ratio for dependence on weight and polarity, and with it the property that any two colors can be compared without knowing how they will be rendered.

References

  1. Web Content Accessibility Guidelines (WCAG) 2.2 — W3C
  2. Multimedia systems and equipment — Colour measurement and management — Part 2-1: Default RGB colour space — sRGB — IEC 61966-2-1:1999
  3. Parameter values for the HDTV standards for production and international programme exchange — ITU-R BT.709
  4. CSS Color Module Level 4 — W3C