How to Build an Image Preview with HTML and CSS
Build an accessible image preview with HTML, CSS, and JavaScript using object URLs, validation, responsive styling, and reliable cleanup.
The simplest reliable image preview uses a labeled <input type='file' accept='image/*'>, reads the first selected file in a change handler, creates a temporary object URL with URL.createObjectURL(file), and assigns that URL to an <img>. Revoke the previous object URL whenever the preview changes or is removed.
This approach previews the file locally before upload. The browser exposes only files the user explicitly selects; your page cannot inspect arbitrary files on the device. The complete example below includes accessible labeling, keyboard focus, type validation, status announcements, responsive CSS, and object-URL cleanup.
1. Complete image preview example
Save this as index.html and open it in a browser:
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Image preview</title>
<style>
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
body {
margin: 0;
padding: 2rem;
background: Canvas;
color: CanvasText;
}
.picker {
display: inline-block;
padding: .65rem 1rem;
border: 1px solid currentColor;
border-radius: .45rem;
cursor: pointer;
}
.picker:focus-within,
.picker:focus-visible {
outline: 2px solid #155eef;
outline-offset: 3px;
}
/* Visually unobtrusive, while still available to keyboard users. */
#image-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.preview-frame {
width: min(100%, 32rem);
aspect-ratio: 4 / 3;
margin-top: 1rem;
display: grid;
place-items: center;
overflow: hidden;
background: #f3f4f6;
border: 1px dashed #9ca3af;
border-radius: .5rem;
}
#preview {
width: 100%;
height: 100%;
object-fit: contain;
}
#status {
margin-top: .75rem;
}
</style>
</head>
<body>
<main>
<h1>Choose an image</h1>
<label class='picker' for='image-input'>Choose an image</label>
<input id='image-input' type='file' accept='image/*'>
<div class='preview-frame' aria-live='polite'>
<img id='preview' alt='Selected image preview' hidden>
<p id='status'>No image selected.</p>
</div>
</main>
<script>
const input = document.querySelector('#image-input');
const image = document.querySelector('#preview');
const status = document.querySelector('#status');
let previewURL = null;
function clearPreview(message = 'No image selected.') {
if (previewURL) {
URL.revokeObjectURL(previewURL);
previewURL = null;
}
image.hidden = true;
image.removeAttribute('src');
status.textContent = message;
}
input.addEventListener('change', () => {
const file = input.files?.[0];
clearPreview();
if (!file) return;
if (!file.type.startsWith('image/')) {
status.textContent = 'Choose an image file.';
return;
}
previewURL = URL.createObjectURL(file);
image.src = previewURL;
image.hidden = false;
status.textContent = `${file.name} selected.`;
});
window.addEventListener('pagehide', () => {
if (previewURL) URL.revokeObjectURL(previewURL);
});
</script>
</body>
</html>
URL.createObjectURL(file) creates a temporary URL identifying the selected File. Each call creates a new URL, so the old one is revoked before another image is shown. MDN documents this lifecycle and the related FileReader.readAsDataURL() alternative in its URL.createObjectURL() and FileReader references.
2. How the preview flow works
- The user activates the real file input through its label.
- The
changeevent exposes the selected file throughinput.files. - The old image and object URL are cleared.
- The file MIME type is checked at runtime.
- An object URL is assigned to the image’s
src. - When the preview is replaced or the page is left, the URL is revoked.
The accept='image/*' attribute filters the picker UI, but it is only a hint. Always validate file.type in JavaScript and validate the file again on your server before storing or processing it.
3. CSS choices for the preview box
object-fit: contain
contain keeps the complete image visible inside the frame. If the image and frame have different aspect ratios, the remaining area is letterboxed.
object-fit: cover
Use cover when the preview must fill the frame. It crops the overflow:
.preview-frame img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
object-position: top, left, or a percentage changes which part remains visible. These properties are defined in MDN’s object-fit documentation.
Responsive dimensions
width: min(100%, 32rem) prevents the preview from overflowing narrow screens. aspect-ratio reserves space before an image loads and keeps a stable layout. For a square avatar, use aspect-ratio: 1; for a landscape card, use 16 / 9.
4. FileReader and data URLs
Use FileReader when another part of your application needs the image as a data URL string, such as sending it inside JSON. Reading is asynchronous:
const input = document.querySelector('#image-input');
const preview = document.querySelector('#preview');
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.addEventListener('load', () => {
preview.src = reader.result;
preview.hidden = false;
});
reader.addEventListener('error', () => {
preview.hidden = true;
});
reader.readAsDataURL(file);
});
Object URLs are usually the better choice for a display-only preview because the browser can reference the selected file directly. Data URLs encode the file as text and can be substantially larger. Choose based on whether you need a string value or only a temporary image source.
5. Multiple image previews
Add multiple, iterate over input.files, and retain every object URL so all of them can be revoked:
<input id='images' type='file' accept='image/*' multiple>
<div id='gallery' class='gallery' aria-live='polite'></div>
<script>
const picker = document.querySelector('#images');
const gallery = document.querySelector('#gallery');
const urls = new Set();
picker.addEventListener('change', () => {
for (const url of urls) URL.revokeObjectURL(url);
urls.clear();
gallery.replaceChildren();
for (const file of picker.files) {
if (!file.type.startsWith('image/')) continue;
const url = URL.createObjectURL(file);
urls.add(url);
const image = document.createElement('img');
image.src = url;
image.alt = file.name;
image.loading = 'lazy';
gallery.append(image);
}
});
window.addEventListener('pagehide', () => {
for (const url of urls) URL.revokeObjectURL(url);
});
</script>
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: .75rem;
}
.gallery img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: .4rem;
}
6. Accessibility checklist
- Connect a visible
<label>to the input with matchingforandidvalues. - Keep the input in the accessibility tree. Avoid
display: noneorvisibility: hiddenwhen keyboard or assistive-technology access is required. - Provide a visible
:focus-visiblecue. - Give the preview meaningful alternative text. For a decorative preview, use
alt=''. - Use an
aria-live='polite'status region for selected, cleared, and invalid states. - Clear the previous image when the user cancels the picker or selects an invalid file.
7. Validation, limits, and safer uploads
Client-side validation improves feedback but does not enforce security. A production upload endpoint should verify the MIME type from the received bytes, enforce a maximum size, decode the image with a trusted library, strip unwanted metadata when appropriate, and store it outside executable paths. Do not trust the original filename or extension.
const MAX_BYTES = 10 * 1024 * 1024;
function validImage(file) {
return file &&
file.type.startsWith('image/') &&
file.size <= MAX_BYTES;
}
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!validImage(file)) {
clearPreview('Select an image smaller than 10 MB.');
return;
}
// Create the object URL only after validation.
});
For very large images, display a small client-side preview but perform resizing, compression, orientation handling, and final validation on the server or in a dedicated image-processing worker.
8. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Nothing appears after selection | The handler is not attached, or the image remains hidden. | Check the input and image selectors, assign src, and set hidden = false. |
| Preview shows non-image files | accept is treated as validation. |
Check file.type.startsWith('image/') and validate again on the server. |
| Memory grows after many selections | Object URLs were never released. | Call URL.revokeObjectURL() when replacing, removing, or leaving the page. |
| The image is cropped unexpectedly | object-fit: cover crops overflow. |
Use contain, or adjust object-position and the frame ratio. |
| Keyboard users cannot reach the picker | The input was hidden with display: none or has no label. |
Use a connected label and a visually hidden technique that preserves focus. |
| Orientation looks wrong | Camera images may contain EXIF orientation metadata. | Normalize orientation during server-side processing or with an image library before final storage. |
| Preview works locally but upload fails | A local object URL is only for this page; it is not a server URL. | Send the original File in FormData to your upload endpoint. |
9. Performance and reliability
- Object URLs avoid creating a base64 copy for display, which reduces unnecessary work for large files.
- Revoke URLs promptly to release browser resources.
- Limit the number and dimensions of previews in multi-file workflows.
- Use
loading='lazy'for galleries that can extend below the fold. - Keep preview rendering separate from upload progress so a slow network does not block local feedback.
- Handle picker cancellation, invalid types, oversized files, decode errors, and upload failures as separate states.
10. Or skip the browser setup
If your goal is to capture a webpage image rather than preview a file selected by a user, ScreenshotNeo provides a one-request screenshot API. It removes cookie and consent banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; and each response reports its verdict in headers. It also provides an MCP server for AI agents and includes 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000 shots. See the ScreenshotNeo API documentation for all options.
cURL
curl -G 'https://api.screenshotneo.com/v1/shot' \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Python
import requests
r = requests.get(
'https://api.screenshotneo.com/v1/shot',
params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'},
timeout=90,
)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)
Node.js
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 failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await require('node:fs').promises.writeFile('shot.webp', image);
Choose PNG, JPEG, or WebP as needed. ScreenshotNeo also supports full-page capture, CSS-selector element capture, custom CSS and JavaScript, device and viewport settings, waits, headers, cookies, blocking rules, caching, signed links, asynchronous jobs, bulk capture, PDFs, and usage reporting. Only clean shots are billed, which makes retries and failed-page handling easier to budget.
Create a free ScreenshotNeo account for 1,000 screenshots each month with no card.
11. FAQ
Can JavaScript preview a file before it is uploaded?
Yes. The selected File can be displayed locally with an object URL or a data URL; no upload is required for the preview.
Why should I revoke an object URL?
Each object URL holds a reference to the selected file. Revoking it when the preview is no longer needed releases that browser resource.
Should I use object URLs or FileReader?
Use object URLs for a display-only preview. Use FileReader when another API specifically needs the image encoded as a data URL string.
Does accept='image/*' guarantee a safe image?
No. It guides the picker. Validate the file in the browser for user feedback and validate its bytes on the server before processing or storing it.
How do I show a cropped avatar instead of the whole image?
Set the frame dimensions and use object-fit: cover. Adjust object-position to control the crop.


