Text Over Images: How to Make It Readable and Pass WCAG
A headline over a photo looks fine in the design file, where you chose the crop. On the live site the photo shifts under the text at every screen width, bright clouds land behind letters, and sometimes the image does not load at all. Text over images is the hardest contrast case to get right and one of the easiest to miss in review. This guide covers what WCAG requires, how to measure it properly, and seven fixes with CSS you can copy.
Reviewed by Khushwant Parihar, CPACC
Updated Understanding WCAG 1.4.3
Free tool: Text Over Image Contrast Checker
Load a screenshot, mark the text and see the worst contrast ratio against every background pixel, a heatmap of where it fails, and the smallest overlay opacity that fixes it. The image is measured in your browser and never uploaded.
Why Text Over Images Is the Hardest Contrast Case
WCAG 1.4.3 Contrast (Minimum) sets a ratio between text and the background it is drawn on. On a flat colour there is one background and one number. On a photo or a gradient the background is different behind every letter, and the criterion has no rule for averaging, so the text has to meet the ratio against all of it. The part of the image with the least contrast is the part someone cannot read.
W3C documents this as a named failure, F83: background images that do not give text enough contrast. Its test starts with a quick check: compare light text with the lightest part of the image, or dark text with the darkest part. If that pair passes, the text passes everywhere. If it does not, you check the background directly behind each letter. The Understanding document adds two rules that matter here: ratios are not rounded, so 4.49:1 fails 4.5:1, and a wide halo around the letters counts as their background.
| Criterion | Level | Normal text | Large text | Applies to |
|---|---|---|---|---|
| 1.4.3 Contrast (Minimum) | AA | 4.5:1 | 3:1 | Text and images of text, at every point behind the letters |
| 1.4.6 Contrast (Enhanced) | AAA | 7:1 | 4.5:1 | The same text, at the stricter level |
| 1.4.11 Non-text Contrast | AA | 3:1 against adjacent colours | Icons, carousel arrows and control borders over the image that people need to understand | |
Large text means at least 18 point (24 CSS pixels), or 14 point bold (about 18.7 CSS pixels). For icons on a gradient, the Understanding document for 1.4.11 says to test the least contrasting area too.
What is exempt. Logos, pure decoration, and text that is an incidental part of a picture with significant other visual content, such as a shop sign in a street photo. A headline, a button label or a call to action placed over a hero image is none of these. It is content, and it has to pass.
How to Measure It: the Worst Point, Not the Average
The usual way to check contrast is to pick two colours and put them into a contrast checker. With an image behind the text, that method fails quietly, for several reasons:
- An eyedropper samples one pixel. You pick the spot where the text looks fine, which is rarely the spot where it fails.
- An average hides the extremes. A background that is dark overall can still have a bright patch exactly where one word sits.
- The design crop is not the live crop. The image you sampled in the design tool is rarely the slice that ends up behind the text at every screen width.
- Letter edges blur. Anti-aliased edges and JPEG noise mix text and background colours, so a pixel next to a letter is not a fair sample of either.
Worked example: white text over a sky
Suppose the average colour behind the headline is #466E96, a mid blue: 5.3:1 against white, a comfortable pass. But one cloud behind the second word is #F0F4F8: 1.1:1, effectively invisible. The average passes; the headline fails. Put a 55% black overlay on the image and that cloud becomes about #6C6E70, which is 5.1:1, and the darker parts of the sky only get better.
Measuring properly means comparing the text colour with every background pixel behind it, skipping the blended pixels right at the letter edges, and reporting the worst. That is what the Text Over Image Contrast Checker does on a screenshot. Alongside the worst point it shows the share of the background that passes and the 5th percentile, which tell you whether the failure is one bright spot to cover or the whole area.
If you want to check by hand, follow F83's quick check: find the lightest point behind light text (or the darkest behind dark text) with the image colour picker, and test that pair in the contrast checker. It works when the extreme point is easy to see, and it is slow and easy to get wrong when it is not.
Seven Fixes, with Copy-Ready CSS
The examples build on one hero pattern: a real img positioned behind the text, so it can be cropped, art-directed and lazy-loaded. The photo here is mood, so it has an empty alt; if your image carries information, give it a description (see how to write alt text).
<section class="hero">
<img class="hero__img" src="harbour.jpg" alt=""
width="1600" height="900">
<div class="hero__text">
<h1 class="hero__title">Summer on the harbour</h1>
<p>Boat trips every hour from 10am</p>
</div>
</section>1. A scrim or gradient overlay
A scrim is a semi-transparent layer between the image and the text. It darkens (or lightens) every pixel by the same proportion, which pulls the brightest highlights down to a level the text can beat.
.hero {
position: relative;
isolation: isolate;
display: grid;
align-items: end;
min-height: 28rem;
background-color: #1d2733; /* fallback, see fix 6 */
color: #fff;
}
.hero__img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: -2;
}
/* The scrim */
.hero::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-color: rgb(0 0 0 / 0.55);
}
.hero__text {
padding: 2rem;
}How to choose the opacity. There are two numbers worth knowing. The first is the smallest opacity that passes for your actual image, which the checker works out from a screenshot. The second is a ceiling that passes for any image. With pure white text, the worst possible pixel is pure white, so if the overlay is strong enough to make white text readable on white, it is strong enough for everything. These are the ceilings, worked out with the WCAG formula:
| Text and overlay | 3:1 (large text) | 4.5:1 (normal text) | 7:1 (AAA) |
|---|---|---|---|
| White text (#fff), black overlay | 42% | 54% | 66% |
| Black text (#000), white overlay | 36% | 46% | 59% |
So a 54% black overlay under white text passes 4.5:1 whatever photo an editor drops in later: pure white becomes about #757575, which is 4.6:1. Add a few points of margin for rounding, which is why the example uses 55%. The ceilings assume the overlay covers every pixel behind the text and that the text is pure white or pure black. Off-white text, a brand-tinted overlay or semi-transparent text all need measuring.
A gradient scrim keeps the rest of the photo bright by darkening only where the text sits. The common mistake is sizing the gradient as a percentage of the whole hero: on a phone the text wraps onto more lines and climbs into the faded part. Put the gradient on the text block instead, solid behind the text and fading only in the top padding:
.hero__text {
padding: 4rem 2rem 2rem;
/* 60% black behind every line of text,
fading out only in the top 4rem of padding */
background-image: linear-gradient(
to top,
rgb(0 0 0 / 0.6) calc(100% - 4rem),
rgb(0 0 0 / 0)
);
}2. A solid text panel
A panel behind the text takes the image out of the equation. An opaque panel is a flat colour you can check with any contrast checker, it survives every crop, and the rest of the photo stays untouched. A semi-opaque panel follows the same opacity ceilings as an overlay. For a headline that wraps, a highlight that follows each line looks lighter than a box.
.hero__panel {
max-width: 36rem;
padding: 1.25rem 1.5rem;
background-color: rgb(15 23 42 / 0.88);
color: #fff;
border-radius: 0.5rem;
}
/* A highlight that follows each line of a wrapped headline */
.hero__title span {
background-color: #0f172a;
color: #fff;
padding: 0.1em 0.35em;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}3. text-shadow: when it is enough and when it is not
The Understanding document for 1.4.3 treats a wide border or halo around the letters as their background, while a thin outline counts as part of the letter. So a dark halo that fully surrounds every stroke can supply the contrast the image cannot. The usual decorative drop shadow does not: it is offset to one side, so one edge of every letter still touches the photo, and its blur fades to almost nothing a pixel or two out.
/* Not enough: offset, soft and faint */
.hero__title {
text-shadow: 2px 2px 4px rgb(0 0 0 / 0.4);
}
/* A halo: stacked shadows that surround every stroke */
.hero__title {
color: #fff;
text-shadow:
0 0 2px #000,
0 0 4px #000,
0 0 8px #000,
0 0 12px rgb(0 0 0 / 0.8);
}A halo can be enough for a large, bold headline over a moderately busy image, once you have measured a screenshot of it. It is not enough for body copy or small text, where a halo muddies the letter shapes, or over very bright, detailed images. And it disappears completely in Windows contrast themes, which remove text shadows (fix 7 covers what happens instead). Use it as a finishing touch, not your only defence.
4. Move the text onto a calm area
The cheapest fix is composition. Choose images with a large, even area (sky, wall, water, shadow) and keep that area behind the text with object-position. When one crop cannot serve every width, art-direct a different image for small screens, or stop overlapping altogether and stack the text under the photo.
/* Keep the calm water behind the text */
.hero__img {
object-position: 30% 50%;
}
@media (max-width: 40rem) {
.hero__img {
object-position: 60% 20%;
}
}
/* Or stop overlapping on small screens:
the text sits on the fallback colour below the photo */
@media (max-width: 40rem) {
.hero { display: block; }
.hero__img { position: static; height: 14rem; }
.hero::before { display: none; }
}<picture>
<source media="(max-width: 40rem)" srcset="harbour-tall.jpg">
<img class="hero__img" src="harbour-wide.jpg" alt="">
</picture>5. Darken or blur the image
The most dependable version of this is editing the image file itself: export it darker, or pull down the highlights in the area behind the text. That costs nothing at runtime and looks the same everywhere. In CSS, a brightness() filter darkens the whole image, and a backdrop-filter blur on the text panel calms busy texture. Blur on its own is not a contrast fix, though: a blurred white cloud is still white. Pair it with a tint and measure the result.
.hero__img {
filter: brightness(0.6);
}
.hero__panel {
background-color: rgb(0 0 0 / 0.55);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}6. A background colour for when the image fails
Images do not always load: slow connections, data savers, blocked requests, CDN outages, and the moment before a lazy-loaded image arrives all leave the text on whatever is behind the image. White text over a missing image usually ends up on white. W3C failure F24 warns against setting a text colour without a background colour, and notes that you should think about contrast for when the image is not displayed. Give the element behind the image a solid colour that passes with the text on its own: in the hero above, #1d2733 gives white text 15.1:1. With a CSS background image, put the colour in the same shorthand.
.banner {
/* The colour shows until, or instead of, the image */
background: #1d2733 url("harbour.jpg") center / cover no-repeat;
color: #fff;
}7. Forced colours and increased contrast
In forced colours mode, which Windows contrast themes switch on, the browser replaces your colours with the user's and, as MDN's forced-colors reference describes, removes text-shadow and box-shadow and drops background images that are not loaded with url(). So a gradient scrim vanishes while the photo stays. To keep text readable, browsers paint a solid backplate behind it. Two rules follow: never set forced-color-adjust: none on hero text, which switches the backplate off, and if you want a guaranteed panel, give the text block system colours.
prefers-contrast: more matches when someone has asked their system for more contrast, such as Increase contrast on macOS. It is a cheap moment to strengthen the overlay or switch to a solid panel.
@media (prefers-contrast: more) {
.hero::before {
background-color: rgb(0 0 0 / 0.75);
}
.hero__panel {
background-color: #0f172a;
}
}
@media (forced-colors: active) {
.hero__text {
background-color: Canvas;
color: CanvasText;
}
}Responsive Pitfalls: the Crop Changes What Sits Behind the Text
Text over an image often passes in the design file and fails in production, because the image and the text move independently. A hero that passes at one width can fail at the next:
- Cover crops differently at every width.
object-fit: coverandbackground-size: coverfill the box by cutting off the top and bottom on wide screens and the sides on narrow ones, so a different slice of the photo lands behind the text. - Text wraps and grows. On a phone the headline takes more lines and climbs into busier parts of the image, or out of a gradient sized for desktop.
- Zoom and reflow move everything. 1.4.4 Resize Text (200%) and 1.4.10 Reflow (320 CSS pixels wide) both change where the text falls. A hero with a fixed
heightandoverflow: hiddenalso clips the text, which fails both; usemin-height. - Text spacing overrides. Users who apply the 1.4.12 Text Spacing values make lines longer and taller, which can push text out of a panel that was sized to fit.
- Art direction and themes. A different image per breakpoint, or per light and dark theme, is a different contrast test each time.
- Editors swap images. A CMS hero that passes today can fail when someone uploads a brighter photo next month. Either retest every change or design for the worst case with an overlay at the ceiling opacity or a solid panel.
Images of Text, Carousels and Video Backgrounds
Text baked into the image file, such as a promotional banner exported from a design tool, cannot be fixed with CSS: an overlay darkens the letters along with the photo. Contrast has to be right in the artwork itself, the alt text has to repeat the words, and the text blurs when zoomed and cannot reflow. 1.4.5 Images of Text asks for real text wherever the design can be achieved with it, with logos the main exception. Live text over an image is almost always the better build.
Carousels multiply the problem: every slide puts a different image behind its text, so every slide needs its own check. A carousel that advances on its own for more than five seconds also needs a way to pause or stop it under 2.2.2 Pause, Stop, Hide, and its arrows and dots over the image need 3:1 under 1.4.11. The accessible carousels lesson covers the rest.
Video backgrounds are the extreme case: the background changes many times a second. Use an overlay at the ceiling opacity or a solid panel, because you cannot measure every frame, and give people a way to pause motion that runs for more than five seconds.
How to Test Text Over Images
A single check at your own screen width proves very little. Test the layouts your users actually get.
Measure each breakpoint in the checker
Paste or drop a screenshot, drag a box around the text, and get the worst ratio, a heatmap of the failing pixels, and overlay CSS at the smallest opacity that passes.
- Screenshot every breakpoint. Capture the hero at each layout width you support, for example 320, 768, 1024 and 1440 CSS pixels, plus your main layout at 200% zoom. Save as PNG; JPEG artefacts around letters add noise.
- Measure each one. Load the screenshot into the checker, mark the text, choose the threshold (4.5:1 for normal text, 3:1 for large text or for icons) and read the worst point. Check text in more than one colour one colour at a time.
- Fix and remeasure. Copy the overlay CSS the tool suggests, or apply a panel, then take a fresh screenshot and measure again.
- Block the image. Use your browser developer tools to block the image request and confirm the fallback background colour keeps the text readable.
- Turn on forced colours and more contrast. Try a Windows contrast theme, or emulate
forced-colors: activeandprefers-contrast: morefrom the Rendering panel in Chrome or Edge DevTools, and check the text is still readable. - Cover every variant. Each carousel slide, each art-directed image, light and dark themes, and any hero an editor can change.
For a page-level view of how the hero looks with colour vision deficiencies or blur, run it through the website vision simulator. For flat-colour text elsewhere on the page, the contrast checker is faster, and the accessible colour palettes guide covers building a palette that passes by design.
Text Over Images Checklist
- Worst point passes. 4.5:1 for normal text and 3:1 for large text against every pixel behind the letters, not the average (1.4.3).
- Icons and controls pass. Arrows, dots and icons over the image reach 3:1 against what is next to them (1.4.11).
- A real fix, not a hope. An overlay, a panel, a calm area or a darker image, rather than a faint drop shadow.
- Opacity chosen on purpose. Measured for the image, or at the ceiling (54% black under white text for 4.5:1) where the image can change.
- Fallback colour set. The text passes on the background colour alone if the image never loads.
- Every breakpoint checked. Including 320 CSS pixels, 200% zoom and any art-directed images.
- No clipping. The hero grows with its text:
min-height, not a fixed height with hidden overflow. - Forced colours safe. No
forced-color-adjust: noneon hero text; readable in a Windows contrast theme. - Live text, not baked-in text. Words are real text over the image, except logos (1.4.5).
- Every slide and frame covered. Each carousel slide checked; video backgrounds use a ceiling overlay or a panel and can be paused.
Check Your Hero Image Now
Measure the text against every pixel behind it and get the overlay that fixes it, without the image leaving your browser.
Frequently Asked Questions
How do you make text over an image accessible?
Make sure the text meets WCAG contrast against every part of the image behind it, not just its average colour: 4.5:1 for normal text and 3:1 for large text. The reliable ways to get there are a semi-transparent overlay (scrim) between the image and the text, a solid or semi-opaque panel behind the text, moving the text onto a calm, even part of the image, or darkening the image itself. Set a background colour behind the image so the text still passes if the image fails to load, and check every screen width, because the crop changes what sits behind the text.
What contrast ratio does text on a background image need?
The same as any text, under WCAG 1.4.3 Contrast (Minimum) at Level AA: 4.5:1 for normal text and 3:1 for large text, which is at least 24 CSS pixels, or about 18.7 pixels if bold. Level AAA (1.4.6) raises that to 7:1 and 4.5:1. Icons and other graphics over the image that people need to understand need 3:1 under 1.4.11. The difference with an image is that the background varies, so the ratio has to hold at the point of lowest contrast behind the text. W3C lists background images without enough contrast as failure F83.
What opacity should an overlay be for white text on an image?
With pure white text and a pure black overlay, 54% opacity is enough for 4.5:1 over any image, because even a pure white pixel darkens to about #757575, which is 4.6:1 against white. For large text at 3:1 the figure is 42%, and for 7:1 it is about 66%. Below those numbers it depends on the photo: a dark image may pass with much less, and the Text Over Image Contrast Checker works out the smallest opacity that passes for your actual image. Allow a few points of margin, and remeasure if the text is off-white or the overlay is tinted.
Does a text-shadow make text over an image accessible?
Sometimes. W3C's guidance for 1.4.3 treats a wide halo that surrounds the letters as their background, so a dense, dark halo can supply the contrast that a busy image cannot. A typical drop shadow, offset to one side, blurred and semi-transparent, leaves parts of every letter against the photo and usually does not pass. Text shadows are also removed entirely in Windows contrast themes. Use a halo only on large headlines, and confirm it by measuring a screenshot rather than judging by eye.
Can I use a normal contrast checker for text on an image?
Only if you test the right pair of colours. A normal checker compares two flat colours, so you would need to find the single lightest point behind light text, or the darkest point behind dark text, and test that. This is W3C's quick check for failure F83. It is easy to pick the wrong pixel, or to test the average colour, which can pass while a bright patch of sky makes a word unreadable. A text over image checker compares the text against every background pixel and reports the worst one.
Does WCAG contrast apply to text that is part of a photo?
Not always. WCAG 1.4.3 exempts text that is incidental, such as a shop sign in a street photo, which is part of a picture with significant other visual content. It also exempts logos and pure decoration. A headline, button label or call to action placed over a hero image is none of those: it is content, so it must meet the ratio. Marketing text baked into a banner image must meet it too, and 1.4.5 Images of Text asks for real text instead wherever possible.
What happens to text over images in Windows high contrast mode?
In forced colours mode, which Windows contrast themes switch on, browsers replace your text and background colours with the user's chosen colours and remove text shadows and box shadows. Gradients set as background images are removed, but photos loaded with url() or an img element stay. To keep text readable, browsers paint a solid backplate behind it. Do not set forced-color-adjust: none on hero text, because that switches the backplate off and leaves the text directly on the photo.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences