Text over images, and the scrim that makes it work
A hero image that averages to mid-gray can still contain a blown-out sky. Solve for the worst pixel, not the average one.
↑ Click any of these to repaint the entire site with it.
The hero section has a photograph and a headline over it. It looks fine with the image you tested. Then marketing swaps the image and the headline vanishes into a cloud.
Text over images is the one place where contrast cannot be checked once and forgotten, because one of the two colors is not a color — it is a distribution.
Solve for the worst pixel#
The mistake is checking against the image's average. A photograph that averages to mid-gray can contain a blown-out sky at 98% lightness and a shadow at 4%. Your headline sits over one specific region, and it fails there or it does not.
The right check: find the extreme closest in lightness to your text color, and solve against that. If your text is white, the brightest pixel in the text region is what will break you. If it is dark, the darkest.
Averages will pass a check that the real page fails.
The scrim#
A scrim is a semi-transparent layer between the image and the text. Its job is to compress the image's range enough that the text has a predictable floor.
.hero {
position: relative;
background: url('photo.jpg') center / cover;
}
.hero::after {
content: '';
position: absolute;
inset: 0;
background: rgb(0 0 0 / 0.55);
}
.hero > * {
position: relative; /* lift content above the scrim */
}The number that matters is 0.55, and it should be solved rather than guessed. Composite the scrim over the worst pixel, measure the contrast against your text, and take the lowest alpha that clears your target. Anything more is unnecessarily obscuring the image you paid for.
Gradient scrims are better#
A flat scrim darkens the whole image uniformly, including the parts with no text on them. A gradient scrim darkens only where the text is:
.hero::after {
background: linear-gradient(
to top,
rgb(0 0 0 / 0.75) 0%,
rgb(0 0 0 / 0.45) 45%,
rgb(0 0 0 / 0) 100%
);
}Full strength at the text end, nothing at the other. The image keeps most of its impact and the text still has its floor.
One detail: that gradient will band on large heroes. It is a long, subtle transition in the dark range, which is the worst case for 8-bit quantisation. Add a few percent of noise.
Alternatives to a scrim#
Tint instead of darken. Instead of black at 55%, use your background color at 65%. The image becomes part of the palette instead of a rectangle stuck on top, and it usually looks considerably more designed.
Duotone the image. Mapping the photograph to two colors from your palette collapses its tonal range, which means the worst case is bounded by construction rather than by luck. It also makes swapped images safe, because every image ends up in the same range.
Blur the region under the text. backdrop-filter: blur() on a panel behind the headline. Effective, and the most expensive of the options at runtime.
Put the text next to the image, not on it. Not a joke. A huge amount of "text over image" is a layout choice nobody interrogated, and a split hero with text on a solid background is more readable, more responsive and easier to localise.
Use a text shadow. Cheap, works, and looks like 2006. A very tight, very dark shadow at low opacity is nearly invisible and buys about a stop of separation. Worth knowing about for the cases where nothing else fits.
The part that gets forgotten#
Whatever you build has to hold when the image fails to load, when it loads slowly, and when the user has data saver on.
.hero {
/* the fallback is the composited color, not the image */
background-color: #1a2432;
background-image: url('photo.jpg');
}Set the background color to something close to the scrimmed result. Then the text is readable during load, after a failure, and in the print stylesheet.
The rule#
If text sits on an image, the design is not finished until you know the number. Not "it looks okay" — the actual alpha, derived from the actual worst case, written down in the stylesheet where the next person can see why it is what it is.