Accessibility · Perception

Measuring Contrast When Text Sits on a Photograph

A single foreground color over an image has a contrast ratio for every backdrop pixel; how to find the worst case, and what scrims, blur, and backdrop filters do to that distribution.

A hero banner with a headline over a photograph is the standard case where contrast stops being a property of a pair of colors. The foreground is one value; the backdrop is a set of values, one per pixel, and they differ. The ratio the reader experiences is not the average and not the ratio computed against the image’s dominant color. It is the ratio at the worst pixel behind the glyph, because legibility fails locally: a headline that is crisp over the sky and unreadable over a dark tree line is unreadable at the tree line.

The distribution, not the average

The first consequence is that a compliance tool which samples one pixel, or which reads a CSS color and an image URL and gives up, is not measuring anything. The meaningful measurement is over the glyph’s own footprint: the bounding boxes of the characters, or more precisely the ink area, since the spaces between letters are covered by the backdrop and do not need to contrast with it.

Two statistics matter and they answer different questions. The minimum over the region tells whether the text ever becomes invisible. A percentile, such as the fifth, tells how much of the text is marginal. A design can pass on the minimum and still feel strained because a third of the headline is hovering near 4.5:1, and it can fail on the minimum because a single dark spot sits under one stem.

There is a subtlety about which pixels are in the region. Text is rendered with antialiasing, so the pixels at glyph edges are blends of the foreground and the backdrop, and the blend happens in the encoded space in the common rendering path. Those edge pixels have intermediate contrast by construction; including them in a minimum drives the number toward 1:1 for any text at all. The region should be the glyph interiors, and the metric should be computed against the declared foreground rather than the rendered blend.

Scrims change the shape of the distribution

The standard remedy is to darken the image behind the text with a gradient overlay: an element painted between the photograph and the text, usually transparent at the far edge and near-opaque at the text edge. This does not set the backdrop to a single color; it applies an affine transform to each pixel.

With a black scrim of opacity a composited over the image in encoded space, a backdrop channel value c becomes (1 - a) c. The luminance is not scaled by (1 - a) because the linearisation is nonlinear, but the effect is monotone: every pixel gets darker, and the darkest pixel stays the darkest. So the minimum contrast for white text improves predictably, and the improvement is largest where the image was brightest. The arithmetic of the compositing step is the same one that governs any translucent layer in a contrast audit, and it is easy to model in the wrong colour space.

That last point is why a top-down gradient works better than a uniform tint of the same average strength. White text fails over bright regions; darkening the bright regions disproportionately is exactly what a gradient anchored at the text does. A uniform 40 percent black overlay reduces the luminance of a bright sky by a large factor and of a dark foreground by a small one, spending contrast where it is not needed.

The gradient’s own falloff creates a second problem. If the scrim is strongest at the very top and the headline sits 80 px lower, the text may straddle the region where the scrim has already thinned. Computing the scrim’s opacity at the text’s actual position, rather than at the element’s, is the difference between a correct calculation and a plausible one.

Blur and backdrop filters

Blurring the backdrop is often presented as a legibility fix. It reduces high-frequency variation, which helps the eye segment glyphs from texture, but it does not raise the minimum contrast much. A blur is a weighted average over a neighbourhood, so it pulls each pixel toward the local mean; a small dark feature surrounded by bright pixels becomes less dark, and a small bright feature becomes less bright. The extreme values contract toward the middle. Since the minimum contrast is determined by the worst pixel, contraction in the direction of the mean helps only when the offending pixel is an outlier, not when a large dark region sits under the text.

A backdrop blur therefore improves the experience of reading over a busy image while leaving the arithmetic nearly unchanged for a genuinely dark region. It is a fix for the case where the text is legible but visually noisy, not for the case where it is illegible.

Blur also costs. A large-radius backdrop filter forces the compositor to read back the region behind the element, which on some renderers means an offscreen pass per frame when the backdrop changes. A static hero image costs nothing after the first paint; a blurred backdrop behind a scrolling list costs on every scroll frame.

def relative_luminance(rgb):
    def lin(c):
        c /= 255
        return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
    r, g, b = map(lin, rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def worst_case_contrast(fg, pixels, sample_step=1):
    """fg: (r, g, b) of the text. pixels: iterable of backdrop RGB triples."""
    lf = relative_luminance(fg)
    worst = 21.0
    for r, g, b in pixels[::sample_step]:
        lb = relative_luminance((r, g, b))
        hi, lo = max(lf, lb), min(lf, lb)
        worst = min(worst, (hi + 0.05) / (lo + 0.05))
    return worst

Sampling every pixel of a large photograph is unnecessary. A stride of a few pixels in each direction, restricted to the glyph boxes, gives the same minimum in practice because the features that matter are larger than the stride. The cost is linear in the sampled area, so a full-screen 1920×1080 hero at stride 4 is about 130,000 evaluations, which is nothing for an offline check and too much for anything in a render path.

Polarity and why white-on-dark is the common choice

Dark text on a light backdrop and light text on a dark one are not symmetric in perception. Light text on a dark ground produces halation: the bright glyph spreads optically, which makes it look bolder and, at small sizes, slightly blurrier. Dark text on a light ground is sharper. For photographs, the asymmetry usually decides the design, because images are more often dark than uniformly light and a dark scrim is cheaper than a light one.

The measurement asymmetry is different and is worth stating precisely. The ratio formula is symmetric in the two luminances, so the same pair scores the same either way. What changes is which pixels of the image matter. With white text, the minimum is at the brightest backdrop pixel; with black text, it is at the darkest. A photograph with a bright sky and a dark foreground has a large luminance range, and the two polarities fail at opposite ends of it. Choosing the polarity by measuring both minimums is a one-line experiment and is routinely skipped.

Where the technique stops working

Any design where the text region contains both very bright and very dark pixels cannot be rescued by a single scrim without effectively painting out the image. If the brightest pixel is at luminance 0.9 and the darkest at 0.01, white text scores about 1.1:1 against the bright pixel and black text about 1.2:1 against the dark one. No intermediate value does better on both: 4.5:1 against 0.9 requires a foreground at or below about 0.16, while 4.5:1 against 0.01 requires one at or above about 0.22, and no single value satisfies both bounds. The scrim has to flatten the range first, which means it must be nearly opaque, at which point the photograph is decoration behind a colored panel.

The honest alternatives are to move the text off the image, to constrain the text region to a part of the image with a narrow range, or to accept the flattening. Each is a design decision with a visible cost, and the arithmetic is what makes the cost explicit rather than a matter of taste. Where the image is genuinely decorative, the text can sit on a solid panel beside it, which removes the problem rather than mitigating it. Where the image carries meaning, the text alternative has to carry that meaning in a form that does not depend on the pixels at all.

One more failure mode is worth naming because it appears only in the rendered state: text over an image that is itself behind a translucent element, or over a video. In the video case the backdrop changes over time, so a measurement on a single frame is a measurement of one moment. The worst case is over the whole clip, and the only practical approach is to bound it structurally — a fixed opaque scrim in the region where text is drawn, rather than a measurement of the frames that happened to be sampled.

References

  1. Web Content Accessibility Guidelines (WCAG) 2.2 — W3C
  2. CSS Backgrounds and Borders Module Level 3 — W3C
  3. Filter Effects Module Level 1 — W3C
  4. CSS Color Module Level 4 — W3C