Accessibility · Testing Accessibility
Measuring Contrast From Rendered Pixels
Why the contrast ratio is simple arithmetic but the colour inputs are not, how effective backgrounds are composited, and what sampling rendered pixels can and cannot settle.
The contrast criterion is the most confidently automated check in the whole guideline set, and the arithmetic behind it fits in a paragraph. Take the relative luminance of the text colour, take the relative luminance of the background, and compute the ratio of the lighter to the darker. The ratio ranges from 1:1 for identical colours to 21:1 for black on white. The thresholds are 4.5:1 for body text, 3:1 for large text defined as at least 18 point or 14 point bold, and 3:1 for graphical objects and interface components under the adjacent criterion.
The arithmetic is not the hard part. The hard part is knowing which two colours to feed into it, and that question has no general answer from the cascade alone.
Luminance and the sRGB nonlinearity
Relative luminance is defined in the specification as a weighted sum of linearized channel values: 0.2126 for red, 0.7152 for green, 0.0722 for blue. The linearization matters and is the step most often skipped in homegrown implementations. An sRGB channel value in the range 0 to 1 is not proportional to light; it is encoded with an approximate power function. The specification’s linearization maps values at or below 0.03928 by dividing by 12.92, and values above by raising to the power 2.4 after adding 0.055 and dividing by 1.055.
Skipping the linearization produces ratios that are wrong in a specific direction: mid-tones are overstated, so a pair of colours that fails at 4.2:1 can appear to pass. The error is largest for saturated colours and for anything far from black or white, which is exactly where designers work.
const lin = c => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
const luminance = ({ r, g, b }) =>
0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255);
const ratio = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05); // the 0.05 offset keeps the ratio finite at black
};
The 0.05 terms in the ratio formula are not decorative. Without them, a pair of very dark colours would produce a ratio near 1 no matter how different they look, and the scale would compress the range where low-vision users need the most separation.
The effective background problem
A text node’s background is not a property; it is the result of compositing every background that paints behind the text, from the nearest ancestor outward, until an opaque layer is reached. A paragraph inside a card inside a page has at least three candidate backgrounds, and any of them may have an alpha channel. Compositing is not simple averaging: each layer is blended with its backdrop using source-over, and the accumulated result depends on the order and on each layer’s alpha.
CSS makes this worse in ways the cascade does not expose. background-image with a gradient produces a continuously varying backdrop, so a single ratio does not exist for a run of text spanning the gradient; the correct answer is a set of ratios, one per sample. A background image produces a backdrop that depends on the image’s pixels and on background-size and background-position. backdrop-filter and mix-blend-mode make the result depend on what is behind the element in a way that is not a tree walk at all. background-clip: text makes the text itself a mask over an image.
Engines handle this by giving up at a defined boundary. The typical implementation walks ancestors, composites solid and semi-transparent colour layers, and returns incomplete when it encounters an image or a gradient. That is why a page with a photographic hero and a gradient call-to-action button produces a long incomplete list, and why the list is exactly the set of elements that a person needs to look at.
Sampling the rendered output
The alternative to reading styles is to read pixels. Render the page, locate the text, and measure the colours actually present in the text’s box. This is the only approach that handles images, gradients, and blend modes, and it has its own set of ambiguities.
The first is antialiasing. Text edges are not the text colour; they are a blend of the text colour and the background, produced by the rasterizer’s coverage values. Sampling the darkest pixel in the box gets close to the text colour for dark-on-light text, and sampling the lightest gets close for light-on-dark, but both are approximations that depend on the font’s weight and the rasterization settings. The practical rule is to sample the interior of thick strokes, or to take a percentile of the luminance distribution rather than an extreme.
The second is that subpixel rendering, where enabled, produces colour fringes: a black glyph on white can have pixels with strongly non-neutral channel values along its edges. Those pixels are not the text colour and including them corrupts the luminance computation. Taking the median of the pixels classified as foreground, rather than the mean, is more stable.
The third is that text is not always a solid colour. A gradient-filled heading, a text shadow used as an outline, and a stroked text effect each produce a set of colours rather than one. The criterion’s intent is that the text as a whole is distinguishable from its background, so the useful measurement is the worst-case local ratio across the text’s extent: for each region of the text, the foreground and background actually adjacent there.
# Worst-case ratio over a grid of cells, rather than one global average.
def lin(c): # sRGB channel in 0..1 to linear light
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
def luminance(rgb):
r, g, b = (lin(v / 255) for v in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def ratio(a, b):
hi, lo = sorted((luminance(a), luminance(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
def median_colour(pixels): # median per channel over an iterable of RGB triples
vals = list(pixels)
if not vals:
return None
chans = list(zip(*vals))
return tuple(sorted(c)[len(c) // 2] for c in chans)
def worst_ratio(img, text_mask, cells=8):
worst = float("inf")
h, w = len(img), len(img[0])
for cy in range(cells):
for cx in range(cells):
ys = range(cy * h // cells, (cy + 1) * h // cells)
xs = range(cx * w // cells, (cx + 1) * w // cells)
fg = median_colour(img[y][x] for y in ys for x in xs if text_mask[y][x])
bg = median_colour(img[y][x] for y in ys for x in xs if not text_mask[y][x])
if fg is None or bg is None:
continue # cell holds only text or only background
worst = min(worst, ratio(fg, bg))
return worst
The grid makes the measurement local. A button whose gradient runs from a light left edge to a dark right edge will pass at one end and fail at the other, and the grid reports the failing end while a single average reports a comfortable pass.
Which state, which pixels
Three further decisions change the result, and a report that does not state them is not reproducible.
Hover and focus states are separate renderings. A link whose default colour passes but whose hover colour is a lighter tint fails the criterion in the state the user is in while reading it with the pointer over it. Testing only the default state is the standard practice and the standard gap; the states are cheap to enumerate because they are enumerated by the CSS.
Forced-colours mode is a different rendering entirely. When the user agent applies a forced colour palette, author colours are overridden for most properties, and the ratio is determined by the palette rather than by the stylesheet. A page that passes in normal mode and fails in forced-colours mode has a real defect, and the defect is usually caused by an author colour applied to a property the palette does not override, such as a background on a pseudo-element used for an icon.
Zoom and text scaling change the size, not the colour, but they change which threshold applies. The large-text exception is defined in points, and a font size specified in CSS pixels converts at 0.75 points per pixel, so 18 point is 24 CSS pixels. At 200 percent zoom the CSS pixel size is unchanged but the rendered size doubles; the threshold is applied to the CSS size, not the rendered one, which is a distinction that some checkers get wrong.
Reading the ratio as a threshold
The number the criterion asks for is a floor, and passing it is a statement about a specific pair of colours under specific conditions. Text at 4.6:1 on a bright display in a dark room is conforming and can be difficult for a reader with reduced contrast sensitivity, particularly when the type is thin or the background is busy. The threshold exists so that the criterion is testable, not because it is the boundary of legibility.
The measurement therefore serves two purposes. As a gate, it is a binary comparison and it belongs in the automated suite, with the incomplete cases routed to review. As a design input, it is a number to be exceeded rather than met, and the useful practice is to measure the page’s distribution of ratios rather than only its failures. A page whose body text sits at 12:1 and whose secondary text sits at 4.6:1 has a different reading experience from one where everything sits at 5:1, and only the distribution shows that.
The remaining limitation is the one that no pixel measurement fixes: the criterion asks whether the text is distinguishable, and a ratio of 4.5:1 between a mid-grey text and a slightly different mid-grey background satisfies it while being hard to read for reasons the arithmetic does not capture. The division between mechanical and judgement applies here as much as anywhere: the number is decidable, the experience is not. The measurement also has to be repeated whenever the layout changes, since a background that was a solid colour can become an image without any change to the text colour, and that change moves the element from the decidable set into the review queue.