Text over a photo is the most common pattern in web design after the paragraph: hero banners, article cards, product tiles, social images. CSS has three clean ways to lay it out, and one rule that decides whether anyone can read the result: contrast. This page shows the three layouts, then measures how strong an overlay has to be for white text to pass WCAG contrast. The contrast numbers were measured in Chrome (Puppeteer 25.7.0, Chrome 152) on 2026-11-07.
1. Absolute positioning
The classic layout: a container that positions its children, the image filling it, the text placed on top.
<figure class="card">
<img src="forest.jpg" alt="Fog over a beech forest at sunrise">
<figcaption class="card-text">Morning light in the old beech forest</figcaption>
</figure>
.card { position: relative; aspect-ratio: 16 / 9; margin: 0; overflow: hidden; }
.card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card-text {
position: absolute; inset: auto 0 0 0; /* stick to the bottom edge */
padding: 1.5rem; color: #fff; font-size: 1.75rem;
}
object-fit: cover crops the photo to fill the box without stretching it (MDN: object-fit); object-position chooses which part survives the crop — object-position: 50% 20% keeps faces near the top. display: block removes the small gap an inline image leaves under itself.
The catch: absolutely positioned text takes no space. If the text is longer than the box, the card does not grow: in our test a 640×360 card held 592 px of text, and overflow: hidden cut off everything above the card's top edge. Fine for a fixed-size banner with short text, wrong for a card whose text length varies.
2. Grid stacking
Put both children into the same grid cell. Nothing is taken out of the flow, so the card grows with its text:
.card { display: grid; margin: 0; }
.card > * { grid-area: 1 / 1; } /* image and text share one cell */
.card img { width: 100%; height: 100%; min-height: 16rem; object-fit: cover; }
.card-text { align-self: end; padding: 1.5rem; color: #fff; z-index: 1; }
align-self: end puts the text at the bottom, center in the middle. The cell is as tall as the taller child, so long text makes the card taller instead of overflowing — with the same 592 px of text, the grid card grew from 360 to 592 px. This is the layout to use for cards in a list.
3. Background image
When the photo is decoration, not content, it can be the container's background:
.hero {
min-height: 24rem; display: grid; place-items: center; padding: 2rem;
background: url("forest.jpg") center / cover no-repeat;
color: #fff; text-align: center;
}
background-size: cover (the / cover in the shorthand) crops like object-fit: cover. A background image has no alt text and is not announced by screen readers, so use it only when the picture adds nothing a reader would miss.
Keeping the text readable
White text on a photo fails exactly where the photo is light: a sky, a white wall, snow. WCAG 2.2 asks for a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (24 px, or 18.66 px bold) (WCAG 2.2, Contrast (Minimum)). Where the background varies, as a photo does, WCAG's technique G18 says the background around the letters can be "chosen or shaded" so that the letters keep that ratio against what is behind them (G18).
The reliable fix is a dark layer between the photo and the text. We rendered a black overlay at different opacities over three backgrounds and measured white text's contrast against the resulting pixel:
| Black overlay | over white #ffffff | over yellow #ffd54f | over sky blue #87ceeb |
|---|---|---|---|
| none | 1.00:1 | 1.41:1 | 1.74:1 |
rgba(0,0,0,.3) |
2.12:1 | 2.90:1 | 3.50:1 |
rgba(0,0,0,.4) |
2.85:1 | 3.83:1 | 4.55:1 |
rgba(0,0,0,.45) |
3.36:1 | 4.47:1 | 5.29:1 |
rgba(0,0,0,.5) |
4.00:1 | 5.26:1 | 6.11:1 |
rgba(0,0,0,.55) |
4.74:1 | 6.13:1 | 7.07:1 |
rgba(0,0,0,.6) |
5.74:1 | 7.28:1 | 8.32:1 |
Read the white column as the worst case: with 55% black, white text passes 4.5:1 over any photo, even a blown-out sky; 45% is enough for large text. A 30% overlay — common in hero banners because it "looks nice" — leaves white text at 2.12:1 on the light parts.
You rarely need the overlay everywhere. A gradient that is dark only where the text sits keeps the rest of the photo bright:
.card-text {
background: linear-gradient(to top, rgba(0,0,0,.75), rgba(0,0,0,.55) 60%, transparent);
padding-top: 4rem; /* room for the fade above the first line */
}
The contrast rule applies to the part behind the letters: check that the gradient is still at least 55% where the top line of text starts, not only at the bottom edge. With this CSS over a pure white photo, the pixel at the top of the first line measured 5.66:1 against white text.
Other tools, and their limits:
text-shadow(0 1px 3px rgba(0,0,0,.6)) softens edges but darkens only a few pixels around each letter; on a light photo the letters still sit on light pixels. Use it with an overlay, not instead.- A solid box behind the text (
background: rgba(0,0,0,.7)on the caption itself, with padding) is the most robust and works with any photo. - Dark text on a light overlay (
rgba(255,255,255,.8)) works the same way in reverse. mix-blend-modeandbackdrop-filter: blur()look striking, but their contrast depends on the photo and cannot be guaranteed.
Responsive details
- Give the container a size:
aspect-ratio,min-heightor a fixed height. An<img>withheight: 100%inside a container without a height collapses to its natural height. - Scale the text with the box using
clamp()—font-size: clamp(1.25rem, 4vw, 2.5rem)— so a headline does not cover the whole photo on a phone. - Keep the text in HTML, not baked into the image file: it stays selectable, translatable and readable by screen readers and search engines.
When you need the result as an image
Everything above is for a web page, where the browser draws the text over the photo each time. Social posts, link previews, email headers and print need a single image file with the text already in it. That is what Shareglint templates do: the same HTML and CSS, drawn by Chrome on our servers, with the text and the photo as fields.
The built-in photo-feature template, for example, puts a title, credit and caption over a photo with a bottom gradient that reaches about 92% black behind the text:
curl -X POST "https://shareglint.com/v1/images?wait=true" \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"template": "photo-feature",
"size": "og",
"modifications": {
"photo": "https://example.com/forest.jpg",
"title": "Morning light in the old beech forest",
"credit": "Ines Varga / Northwind Photo"
}
}'
The response's url is the finished image. Your own templates can use any of the three layouts on this page; fonts come from our installed list (fonts and emoji). The free template preview shows the result before you use any quota, and the template gallery has other text-over-photo layouts — meme-caption, real-estate, thumbnail-face — to start from.