How to Add Text Over an Image in React
Overlay live, accessible text on an image in React with a positioned wrapper, responsive sizing, and CSS that keeps the caption readable.
To add live text over an image in React, put both elements inside the same wrapper, set the wrapper to position: relative, and position the text with position: absolute. React renders the image and text as normal HTML; CSS controls their placement. The text remains selectable and can reflow on different screens.
1. Build the basic overlay
This component accepts an image source, alternative text, and arbitrary React content for the overlay. Save the JSX in a component such as ImageWithText.jsx, and put the CSS in a stylesheet imported by your app.
function ImageWithText({ src, alt, children }) {
return (
<div className="image-overlay">
<img className="image-overlay__image" src={src} alt={alt} />
<div className="image-overlay__text">{children}</div>
</div>
);
}
export default function Example() {
return (
<ImageWithText src="/images/coast.jpg" alt="Rocky coast beside a blue sea">
<h2>A quieter kind of getaway</h2>
<p>Plan a weekend by the water.</p>
</ImageWithText>
);
}
.image-overlay {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
}
.image-overlay__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.image-overlay__text {
position: absolute;
inset: auto 1rem 1rem;
color: white;
background: rgb(0 0 0 / 65%);
padding: 0.75rem 1rem;
}
The wrapper stays in normal document flow and establishes the containing block for its absolutely positioned child. Without a positioned ancestor, the text may be placed relative to a different ancestor or the page. Keeping the image and text in one wrapper makes them resize and move together.
2. Choose image sizing and placement
The aspect-ratio is a design choice. Pick a ratio that fits the image and layout. The image is resized to the wrapper, and object-fit decides how its intrinsic proportions fit that box.
| Value | Effect | Use when |
|---|---|---|
cover |
Fills the box while preserving the image ratio; some edges can be cropped. | The frame must be filled and edge cropping is acceptable. |
contain |
Shows the full image while preserving its ratio; empty space can remain. | Every part of the image must stay visible. |
fill |
Fills the box and can distort the image when ratios differ. | Only when distortion is an intentional design choice. |
object-fit matters when the rendered image has dimensions that differ from its intrinsic ratio. To move the visible crop, set object-position, for example object-position: center 35%;. To change text placement, adjust the absolute inset: inset: 1rem 1rem auto; places it at the top, while inset: 50% auto auto 50%; transform: translate(-50%, -50%); centers it.
.image-overlay__image {
object-fit: cover;
object-position: center 35%;
}
.image-overlay__text--top {
inset: 1rem 1rem auto;
}
.image-overlay__text--center {
inset: 50% auto auto 50%;
transform: translate(-50%, -50%);
}
3. Make the overlay responsive and readable
The wrapper can grow with its parent while preserving the chosen ratio. Text can use responsive padding and font sizes, but it must still fit at narrow widths and when users zoom.
.image-overlay__text {
position: absolute;
inset: auto 1rem 1rem;
max-width: min(34rem, calc(100% - 2rem));
color: white;
background: rgb(0 0 0 / 72%);
padding: clamp(0.625rem, 2vw, 1rem);
border-radius: 0.25rem;
}
.image-overlay__text h2 {
margin: 0 0 0.4rem;
font-size: clamp(1.125rem, 3vw, 2rem);
line-height: 1.15;
}
.image-overlay__text p {
margin: 0;
}
Check the actual text against the background where it appears. WCAG guidance specifies a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text, subject to the criterion’s exceptions. A translucent panel or gradient can stabilize contrast over a varied photograph; verify the resulting colors rather than assuming transparency guarantees readability.
4. Use meaningful image alternatives
The overlay text and image are separate content. For an informative image, describe its relevant visual content in alt; do not use the overlay caption as a substitute unless it accurately describes the image. For a purely decorative image whose meaning is already supplied by nearby text, use alt="".
<img src="/images/coast.jpg" alt="Rocky coast beside a blue sea" />
<img src="/images/texture.jpg" alt="" />
Keep the overlay words as JSX text when they are page content. Text baked into pixels does not remain selectable and can scale poorly. If the end goal is a single composed image, treat that as a separate rendering task and provide an accessible description for the resulting image-like content.
5. Use dynamic styles when values come from props
For a small number of runtime values, React’s style prop accepts a JavaScript object with camel-cased CSS properties. A class-based stylesheet is usually easier to maintain for shared layout rules.
function PositionedCaption({ src, alt, children, position = "bottom" }) {
const style = position === "top"
? { inset: "1rem 1rem auto" }
: { inset: "auto 1rem 1rem" };
return (
<div className="image-overlay">
<img className="image-overlay__image" src={src} alt={alt} />
<div className="image-overlay__text" style={style}>
{children}
</div>
</div>
);
}
Keep the wrapper’s positioning and image sizing in CSS; use inline styles only for the values that actually vary. Avoid constructing CSS strings from untrusted input.
6. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Text appears at the page corner or in an unexpected place. | The intended wrapper has no positioning context. | Set position: relative on the shared wrapper and keep the overlay inside it. |
| Text drifts when the image resizes. | The image and caption are not sized and positioned against the same wrapper. | Keep both inside the wrapper; size the image to its width and height. |
| The photo looks stretched. | The image is forced into unequal dimensions with the default fit behavior. | Use cover to crop or contain to preserve the whole image. |
| Important subject matter is cropped. | cover fills the frame by clipping edges. |
Use contain, change the aspect ratio, or adjust object-position. |
| Caption is hard to read on some photos. | Contrast changes across the image. | Add a sufficiently opaque backing or gradient and check contrast at the text location. |
| Caption is clipped on mobile. | The overlay box is too large for the image frame, often combined with overflow: hidden. |
Allow the caption to wrap, reduce spacing at a breakpoint, or place it below the image on very small screens. |
| Screen readers repeat or miss the image meaning. | The alternative text does not match whether the image is informative or decorative. | Use a concise descriptive alt for informative images and an empty alt for decorative ones. |
7. Performance and reliability
A CSS overlay adds ordinary DOM elements; the browser does not need to create a second bitmap just to display live text. Keep image dimensions reserved with an aspect ratio to reduce layout movement while the image loads. For below-the-fold images, native lazy loading can defer fetching:
<img
src="/images/coast.jpg"
alt="Rocky coast beside a blue sea"
loading="lazy"
/>
Do not lazy-load an image that is immediately visible at the top of the page without considering the effect on its initial appearance. Use appropriately sized image assets and set a stable wrapper ratio so a slow request or a failed image does not cause surrounding content to jump. If the image is essential, provide a sensible background color or fallback treatment and ensure the overlay remains understandable without it.
8. Test the layout with a screenshot
Check the component at narrow and wide viewport sizes, with long captions, and with photos that have both light and dark regions. A screenshot helps reveal clipping, poor contrast, and an unexpected crop. You can capture the rendered page in a browser automation setup or use a screenshot API.
DIY browser capture with Playwright
Install Playwright in a project that already has a running local React app:
npm install -D playwright
Save this as capture.mjs, start the app separately, and set PAGE_URL to its address:
import { chromium } from "playwright";
const url = process.env.PAGE_URL ?? "http://127.0.0.1:3000";
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(url, { waitUntil: "networkidle" });
await page.screenshot({ path: "overlay-mobile.png", fullPage: true });
} finally {
await browser.close();
}
For a wide check, change the viewport to a desktop size and save a second capture. If the page keeps polling or streaming, networkidle may never arrive; use domcontentloaded and then wait for a known component selector.
9. Performance, reliability, and cost of screenshot checks
Local browser capture has no per-shot API charge, but it uses browser CPU and memory, needs a browser runtime in the environment, and may need network access to load remote images. Keep the browser open for a batch of captures and close it in a finally block. Set an explicit viewport and wait for a specific selector when the page’s network activity is unpredictable. Avoid waiting indefinitely for every request to finish.
A hosted screenshot service can simplify scheduled checks or capture from environments that do not run browsers. Compare the requested output, viewport controls, wait options, response handling, and pricing before adopting one. Treat timeouts and incomplete page loads as separate from a successful visual result, and retain the URL and viewport alongside each saved image so a capture can be reproduced.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request can return a PNG, JPEG, WebP, or PDF. See the API documentation for parameters and response details.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write("shot.webp", res);
- Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers identify the page verdict and billing status.
- An MCP server provides
take_screenshot,get_page_info, andcapture_pdftools for Claude, Cursor, and other MCP clients. - The free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 shots; all features are on every plan.
Start with 1,000 free screenshots a month, no card required.
FAQ
Does the text become part of the image?
No. With this approach it is HTML text layered over an image, so it remains selectable and can be restyled independently.
Should I use React Native for this example?
No. This example targets React for the web and uses HTML and CSS. React Native has different image and view components.
Can the whole image be clickable?
Yes. Use an appropriate link or button around the component when that matches the interaction, and give it an accessible name. Avoid nesting interactive elements inside another link.
How can I put the caption below the image on small screens?
At a breakpoint, change the wrapper to a normal layout and reset the text positioning, or render the caption after the image in document flow. Ensure the image still has a useful alternative text.


