Avatar Upload — Profile Picture Preview Snippet
Avatar Upload · Forms · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="au" id="auCard">
<div class="au-banner"></div>
<div class="au-body">
<label class="au-ring" title="Upload a profile photo">
<input type="file" class="au-input" accept="image/*" aria-label="Upload a profile photo">
<img class="au-img" alt="Your profile photo" hidden>
<span class="au-placeholder">
<svg viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<span class="au-drop">Drop here</span>
<span class="au-edit">
<svg viewBox="0 0 24 24"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</span>
</label>
<h2 class="au-name">Profile photo</h2>
<p class="au-hint" id="auHint">Drag & drop or browse. JPG, PNG or WebP, up to 5 MB.</p>
<p class="au-error" id="auError" role="alert" hidden></p>
<div class="au-actions">
<button type="button" class="au-btn" id="auPick">Upload photo</button>
<button type="button" class="au-remove" hidden>Remove</button>
</div>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: radial-gradient(circle at 20% 10%, #e0e7ff, transparent 45%), #f1f5f9;
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
padding: 24px;
}
.au {
width: 100%; max-width: 340px;
background: #fff;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 20px 50px rgba(30, 41, 59, 0.14), 0 2px 6px rgba(30, 41, 59, 0.06);
text-align: center;
}
.au-banner {
height: 96px;
background: linear-gradient(135deg, #6366f1, #8b5cf6 55%, #ec4899);
}
.au-body { padding: 0 28px 28px; }
/* Avatar ring sits half over the banner */
.au-ring {
position: relative;
width: 124px; height: 124px;
margin: -62px auto 16px;
border-radius: 50%;
background: #eef2ff;
border: 5px solid #fff;
box-shadow: 0 0 0 2px #c7d2fe, 0 10px 24px rgba(79, 70, 229, 0.25);
display: grid; place-items: center;
cursor: pointer; overflow: hidden;
transition: box-shadow 0.2s, transform 0.2s;
}
.au-ring:hover { transform: scale(1.03); box-shadow: 0 0 0 2px #818cf8, 0 12px 28px rgba(79, 70, 229, 0.32); }
.au-ring:focus-within { box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.45), 0 10px 24px rgba(79, 70, 229, 0.25); }
.au-input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.au-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.au-placeholder svg { width: 46px; height: 46px; fill: none; stroke: #818cf8; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
/* Hover overlay with the camera icon */
.au-edit {
position: absolute; inset: 0;
display: grid; place-items: center;
background: rgba(30, 27, 75, 0.5);
opacity: 0; transition: opacity 0.2s;
}
.au-edit svg { width: 28px; height: 28px; fill: none; stroke: #fff; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.au-ring:hover .au-edit { opacity: 1; }
/* Shown only while a file is dragged over the card */
.au-drop {
position: absolute; inset: 0;
display: grid; place-items: center;
background: rgba(99, 102, 241, 0.88);
color: #fff; font-size: 13px; font-weight: 700;
opacity: 0; pointer-events: none; transition: opacity 0.15s;
}
.au.dragging .au-drop { opacity: 1; }
.au.dragging .au-ring { transform: scale(1.06); }
.au-ring.has-img .au-placeholder { display: none; }
.au-name { font-size: 19px; font-weight: 800; color: #0f172a; letter-spacing: -0.01em; }
.au-hint { font-size: 13px; line-height: 1.5; color: #64748b; margin: 6px 0 0; }
.au-error { font-size: 13px; font-weight: 600; color: #dc2626; background: #fef2f2; border-radius: 10px; padding: 8px 12px; margin-top: 12px; }
.au-actions { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 20px; }
.au-btn {
font: inherit; font-size: 14px; font-weight: 700; color: #fff;
background: linear-gradient(135deg, #6366f1, #7c3aed);
border: none; border-radius: 12px; padding: 10px 20px; cursor: pointer;
box-shadow: 0 8px 18px rgba(99, 102, 241, 0.35);
transition: transform 0.15s, box-shadow 0.15s;
}
.au-btn:hover { transform: translateY(-1px); box-shadow: 0 12px 22px rgba(99, 102, 241, 0.42); }
.au-btn:focus-visible { outline: 3px solid rgba(99, 102, 241, 0.4); outline-offset: 2px; }
.au-remove {
font: inherit; font-size: 14px; font-weight: 600; color: #475569;
background: #f1f5f9; border: none; border-radius: 12px; padding: 10px 16px; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.au-remove:hover { background: #fee2e2; color: #dc2626; }function initAvatarUpload() {
const card = document.querySelector('.au');
if (!card) return; // not mounted yet
const ring = card.querySelector('.au-ring');
const input = card.querySelector('.au-input');
const img = card.querySelector('.au-img');
const pickBtn = card.querySelector('.au-btn');
const removeBtn = card.querySelector('.au-remove');
const hint = card.querySelector('.au-hint');
const error = card.querySelector('.au-error');
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
const DEFAULT_HINT = hint.textContent;
let currentUrl = null;
function formatSize(bytes) {
return bytes < 1048576 ? Math.round(bytes / 1024) + ' KB' : (bytes / 1048576).toFixed(1) + ' MB';
}
function showError(msg) { error.textContent = msg; error.hidden = false; }
function setFile(file) {
error.hidden = true;
if (!file.type.startsWith('image/')) return showError('Please choose an image file.');
if (file.size > MAX_BYTES) return showError('That image is ' + formatSize(file.size) + '. Max size is 5 MB.');
if (currentUrl) URL.revokeObjectURL(currentUrl);
currentUrl = URL.createObjectURL(file); // instant local preview, no upload
img.src = currentUrl;
img.hidden = false;
ring.classList.add('has-img');
hint.textContent = file.name + ' · ' + formatSize(file.size);
pickBtn.textContent = 'Change photo';
removeBtn.hidden = false;
}
input.addEventListener('change', () => { if (input.files[0]) setFile(input.files[0]); });
pickBtn.addEventListener('click', () => input.click());
removeBtn.addEventListener('click', () => {
if (currentUrl) { URL.revokeObjectURL(currentUrl); currentUrl = null; }
input.value = '';
img.hidden = true; img.removeAttribute('src');
ring.classList.remove('has-img');
hint.textContent = DEFAULT_HINT;
pickBtn.textContent = 'Upload photo';
error.hidden = true;
removeBtn.hidden = true;
});
// Drag and drop anywhere on the card
['dragenter', 'dragover'].forEach((t) => card.addEventListener(t, (e) => {
e.preventDefault(); card.classList.add('dragging');
}));
['dragleave', 'drop'].forEach((t) => card.addEventListener(t, (e) => {
e.preventDefault();
if (t === 'dragleave' && card.contains(e.relatedTarget)) return;
card.classList.remove('dragging');
}));
card.addEventListener('drop', (e) => {
const file = e.dataTransfer.files[0];
if (!file) return;
try { input.files = e.dataTransfer.files; } catch (err) {} // keeps the file in the form
setFile(file);
});
}
// Run after the DOM mounts (framework exports run snippet JS before render)
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAvatarUpload);
else requestAnimationFrame(initAvatarUpload);Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Avatar Upload — Circular Dropzone, Instant Preview & Camera Edit Badge
An avatar upload — a circular control for choosing a profile picture with an instant preview — is one of the most-searched form snippets because nearly every app with user accounts (and every profile card) needs one, and doing it well (preview, remove, no broken images, no memory leaks) takes more than a bare file input. This snippet is a complete, accessible avatar uploader: a circular dropzone with a placeholder icon, an instant local preview of the chosen image, a camera edit badge, and a remove button — all built on a real <input type="file"> so it submits and is keyboard-operable.
Instant preview with createObjectURL
The moment a user picks an image, it appears in the circle — no upload, no server round-trip. The change handler calls URL.createObjectURL(file), which creates a temporary local URL pointing at the chosen file in memory, and sets it as the <img> source. This is the standard, efficient way to preview a selected file: it is instant and does not read the whole file into a base64 string the way FileReader does. The image is shown with object-fit: cover so any aspect ratio fills the circle without distortion — a portrait or landscape photo is cropped to a clean square avatar automatically (for manual framing, add the image cropper).
Avoiding the memory leak (revokeObjectURL)
Object URLs hold the file in memory until you release them, so a naive implementation that calls createObjectURL on every change slowly leaks memory. This snippet tracks the current URL in a currentUrl variable and calls URL.revokeObjectURL(currentUrl) before creating a new one (when the user picks a different photo) and when removing the photo. This cleanup is the detail most avatar-upload tutorials miss, and it is what makes the component safe to use repeatedly. It is the object-URL equivalent of clearing up after yourself.
A profile-card presentation
The uploader is styled as a small profile card: a gradient banner with the avatar ring overlapping its lower edge (a negative top margin), so even the empty state looks like a finished profile header. The ring has a white border and a soft indigo glow, with an "Upload photo" button beneath it for visitors who don't realise the circle is clickable.
Empty, hover, drag and filled states
The control has four visual states. Empty: a person-silhouette placeholder inside the ring. Hover: the ring lifts slightly and a dark overlay with a camera icon fades in. Dragging: a file dragged over the card turns the ring indigo with a "Drop here" label. Filled: a .has-img class hides the placeholder, the file name and size replace the hint text, and the button changes to "Change photo".
Drag and drop, with validation
The card listens for dragenter, dragover, dragleave and drop. On drop, the file is assigned back to the real input (input.files = e.dataTransfer.files) so it still submits with the form, then it goes through the same setFile() function as a normal pick. That function rejects non-image files and anything over 5 MB (change MAX_BYTES) with an inline role="alert" message, before any preview is created.
The remove control
Letting users undo a choice is essential. A "Remove photo" button (hidden until a photo is set) clears everything: it revokes the object URL, resets the file input (input.value = ''), hides and unsets the <img>, drops the .has-img class back to the empty placeholder state, and hides itself. Resetting input.value is the correct way to clear a file input (you cannot set its value for security reasons, but you can clear it), which also means picking the *same* file again afterward still fires a change event.
Built on a real, accessible file input
The whole control is a <label> wrapping a hidden-but-present <input type="file" accept="image/*">. The input is hidden with opacity: 0 and 1px size (not display: none, which would remove it from the tab order), so it stays focusable and keyboard-operable, and .au-ring:focus-within shows a focus ring when it is focused. Because it is a genuine file input, the avatar is included in a normal form submit under its name, the OS file picker works, and screen readers announce it as a file upload. The accept="image/*" attribute filters the picker to images, and the handler double-checks file.type.startsWith('image/') before previewing.
Customizing the uploader
Re-theme by changing the banner gradient, the ring glow, the hover overlay, the button color, and the focus ring. Resize by changing the ring's width/height (keep it a circle) and scaling the badge. To enforce a max file size, check file.size in the handler and show an error if it is too large. To crop to a square before upload, pair this with a cropping step, or rely on object-fit: cover plus a server-side crop. To actually upload, send the file (from input.files[0]) via fetch with FormData when the form submits — the preview and the upload are independent, so the UI stays responsive. The structure — label, hidden input, img, placeholder, badge, remove — stays the same.
Accessibility and behaviour notes
Keep the native input so the control is keyboard-focusable and submits with the form; :focus-within makes keyboard focus visible. Give the <label> a title or visually-hidden text like "Upload a profile photo" so its purpose is announced (the placeholder icon alone is not descriptive). When a photo is loaded, set a meaningful alt on the <img> (e.g. "Your profile photo") rather than leaving it empty, so screen-reader users know an image is present. Because the preview is purely local until submit, nothing is uploaded or stored without the user completing the form — a privacy-friendly default.
Step by step
How to Use
- 1Copy the controlCopy the .au card with its .au-ring label, hidden <input type="file">, <img> preview, placeholder, hover overlay, and the Upload and Remove buttons, plus the script.
- 2Keep the object-URL cleanupThe handler revokes the previous URL.createObjectURL before making a new one (and on remove). Keep this — it prevents a memory leak from repeated previews.
- 3Wire up the actual uploadOn form submit, send input.files[0] via fetch with FormData. The local preview is independent, so the UI stays instant.
- 4Set the size limitsetFile() already rejects non-images and files over MAX_BYTES (5 MB), showing an inline error. Change the constant to suit your server.
- 5Try drag and dropDrag an image onto the card. It highlights, then previews the file and shows its name and size.
- 6Re-theme and resizeChange the banner gradient, ring glow, hover overlay, button, and focus ring; resize by changing the ring width/height (keep it circular).
- 7Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
In the file input's change handler, call URL.createObjectURL(file) to get a temporary local URL and set it as an <img> src. The preview is instant and does not upload anything or read the file into a base64 string like FileReader does. Use object-fit: cover so any photo fills the avatar circle cleanly.
Object URLs keep the file in memory until released. If you call createObjectURL on every change without revoking the old one, you leak memory. This snippet tracks the current URL and revokes it before creating a new one and when the photo is removed — the cleanup most tutorials skip.
Revoke the object URL, set input.value = "" to reset the file input, hide and unset the <img>, and remove the filled-state class. Resetting input.value is the correct way to clear a file input and also lets the same file be re-selected afterward.
On form submit, take input.files[0], put it in a FormData, and POST it with fetch. The local preview is independent of the upload, so the UI stays instant; the file is only sent when the user completes the form.
Yes. It keeps a real <input type="file"> (hidden via opacity, not display:none) so it is keyboard-focusable and submits with the form, with a focus-within ring. Add a title or visually-hidden label to the control and a meaningful alt on the loaded image so screen-reader users understand its purpose.
Yes. Click "JSX" for a React component or "Tailwind" for a React + Tailwind version. In React, hold the preview URL in useState, create it in onChange and revoke it in a useEffect cleanup, and render the <img> or placeholder based on whether a URL exists.