Media Upload Grid — Free HTML CSS JS Snippet
Media Upload Grid · Forms · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="wrap">
<div class="uploader">
<h2 class="heading">Upload Photos</h2>
<p class="sub">Add up to 12 images. Drag & drop or click to browse.</p>
<div class="dropzone" id="dropzone" onclick="triggerInput()" ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p class="dz-text">Drop images here or <span class="dz-link">click to upload</span></p>
<p class="dz-hint">JPG, PNG, GIF, WebP — max 5MB each</p>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none" onchange="onFilePick(event)">
<div class="grid" id="previewGrid"></div>
<div class="footer" id="footer" style="display:none">
<span id="fileCount">0 files selected</span>
<div class="footer-btns">
<button class="btn-clear" onclick="clearAll()">Clear All</button>
<button class="btn-upload" onclick="fakeUpload()">Upload Files</button>
</div>
</div>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px; }
.wrap { width: 100%; max-width: 640px; }
.uploader { background: #fff; border-radius: 20px; box-shadow: 0 4px 24px rgba(0,0,0,0.07); padding: 28px; }
.heading { font-size: 18px; font-weight: 800; color: #0f172a; margin-bottom: 4px; }
.sub { font-size: 13px; color: #64748b; margin-bottom: 20px; }
.dropzone { border: 2px dashed #e2e8f0; border-radius: 16px; padding: 36px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fafafa; display: flex; flex-direction: column; align-items: center; }
.dropzone:hover, .dropzone.active { border-color: #6366f1; background: rgba(99,102,241,0.03); }
.dz-text { font-size: 14px; color: #475569; margin: 12px 0 6px; }
.dz-link { color: #6366f1; font-weight: 700; text-decoration: underline; }
.dz-hint { font-size: 12px; color: #94a3b8; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin-top: 16px; }
.preview-card { position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9; }
.preview-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.preview-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background 0.15s; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px; }
.preview-card:hover .preview-overlay { background: rgba(0,0,0,0.45); }
.preview-name { font-size: 10px; color: #fff; font-weight: 600; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preview-card:hover .preview-name { opacity: 1; }
.remove-btn { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; background: rgba(15,23,42,0.7); border: none; border-radius: 50%; color: #fff; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; padding: 0; line-height: 1; }
.preview-card:hover .remove-btn { opacity: 1; }
.status-chip { position: absolute; bottom: 6px; right: 6px; font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 20px; opacity: 0; transition: opacity 0.15s; }
.status-chip.uploading { background: rgba(14,165,233,0.9); color: #fff; }
.status-chip.done { background: rgba(34,197,94,0.9); color: #fff; }
.status-chip.error { background: rgba(239,68,68,0.9); color: #fff; }
.preview-card.is-uploading .status-chip { opacity: 1; }
.preview-card.is-done .status-chip { opacity: 1; }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9; }
.footer span { font-size: 13px; color: #64748b; }
.footer-btns { display: flex; gap: 8px; }
.btn-clear { background: none; border: 1px solid #e2e8f0; color: #64748b; padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; }
.btn-clear:hover { background: #f8fafc; color: #1e293b; }
.btn-upload { background: #6366f1; color: #fff; border: none; padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background 0.15s; }
.btn-upload:hover { background: #4f46e5; }
.btn-upload:disabled { background: #e2e8f0; color: #94a3b8; cursor: not-allowed; }var MAX_FILES = 12;
var MAX_MB = 5;
var files = [];
function triggerInput() {
document.getElementById('fileInput').click();
}
function onDragOver(e) {
e.preventDefault();
document.getElementById('dropzone').classList.add('active');
}
function onDragLeave() {
document.getElementById('dropzone').classList.remove('active');
}
function onDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('active');
addFiles(Array.from(e.dataTransfer.files));
}
function onFilePick(e) {
addFiles(Array.from(e.target.files));
e.target.value = '';
}
function addFiles(newFiles) {
var imageFiles = newFiles.filter(function(f) { return f.type.startsWith('image/'); });
var remaining = MAX_FILES - files.length;
imageFiles.slice(0, remaining).forEach(function(file) {
if (file.size > MAX_MB * 1024 * 1024) { alert(file.name + ' exceeds ' + MAX_MB + 'MB limit.'); return; }
files.push(file);
renderCard(file, files.length - 1);
});
updateFooter();
}
function renderCard(file, idx) {
var grid = document.getElementById('previewGrid');
var card = document.createElement('div');
card.className = 'preview-card';
card.id = 'card-' + idx;
var url = URL.createObjectURL(file);
card.innerHTML = '<img class="preview-img" src="' + url + '" alt="' + file.name + '"><div class="preview-overlay"><span class="preview-name">' + file.name + '</span></div><button class="remove-btn" onclick="removeFile(' + idx + ')" title="Remove">×</button><span class="status-chip"></span>';
grid.appendChild(card);
}
function removeFile(idx) {
files[idx] = null;
var card = document.getElementById('card-' + idx);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.2s'; setTimeout(function() { card.remove(); }, 200); }
updateFooter();
}
function updateFooter() {
var count = files.filter(Boolean).length;
var footer = document.getElementById('footer');
footer.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('fileCount').textContent = count + ' file' + (count !== 1 ? 's' : '') + ' selected';
}
function clearAll() {
files = [];
document.getElementById('previewGrid').innerHTML = '';
updateFooter();
}
function fakeUpload() {
var cards = document.querySelectorAll('.preview-card');
var btn = document.querySelector('.btn-upload');
btn.disabled = true;
btn.textContent = 'Uploading...';
cards.forEach(function(card, i) {
var chip = card.querySelector('.status-chip');
chip.textContent = 'Uploading';
chip.className = 'status-chip uploading';
card.classList.add('is-uploading');
setTimeout(function() {
card.classList.remove('is-uploading');
card.classList.add('is-done');
chip.textContent = 'Done';
chip.className = 'status-chip done';
}, 800 + i * 300);
});
setTimeout(function() {
btn.disabled = false;
btn.textContent = 'Upload Files';
}, 800 + cards.length * 300);
}Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), 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
Media Upload Grid — Drag and Drop, Thumbnail Previews, and Per-File Status

A media upload grid lets users select multiple image files at once and see thumbnail previews before upload — the standard pattern for photo galleries, product image uploads, social media posts, and profile media. This snippet provides a drag-and-drop zone, click-to-browse via a hidden file input, a responsive thumbnail grid using URL.createObjectURL, per-file remove buttons, a file count footer with upload and clear buttons, and a simulated per-file upload status (uploading/done) demonstration.
The drag-and-drop zone
onDragOver(e) calls e.preventDefault() — this is required to allow the drop event to fire (browsers block drop by default). It also adds the .active class for the visual hover state. onDragLeave removes .active. onDrop(e) calls e.preventDefault() to prevent browser default file-open behavior, removes .active, and calls addFiles() with the dropped FileList items.
URL.createObjectURL for instant previews
URL.createObjectURL(file) generates a temporary blob:// URL from a local File object. This URL can be used directly as an img src — no upload is needed for the preview. The object URL is valid for the lifetime of the page. For cleanup, call URL.revokeObjectURL(url) when the card is removed to free browser memory.
File validation
addFiles() filters for image/* MIME type and enforces MAX_MB (5MB) per file and MAX_FILES (12) total. Files that fail the size check show an alert. The remaining count limit prevents adding more than MAX_FILES thumbnails.
The remove pattern
removeFile(idx) sets files[idx] = null (preserving array indices for other cards) and animates the card out with opacity and scale transitions before removing it from the DOM. A null-check in updateFooter() uses files.filter(Boolean).length for the accurate remaining count.
The simulated upload status
fakeUpload() demonstrates the per-card status chip pattern: uploading (blue) → done (green). In production, replace the setTimeout logic with real fetch() or XMLHttpRequest calls that update each card's chip based on the upload response.
Step by step
How to Use
- 1Click the drop zone or drag images onto itClick the dashed drop zone to open the file browser. Select one or more image files (JPG, PNG, GIF, WebP). Or drag image files directly from your desktop onto the zone — the border turns indigo while dragging.
- 2Preview and remove imagesSelected images appear as square thumbnails in a responsive grid. Hover any thumbnail to reveal the filename and an × remove button. Click × to remove that image from the selection.
- 3Clear all or uploadThe footer shows the file count. Click Clear All to remove all selections. Click Upload Files to see the simulated per-card upload status (replace with a real API call in production).
- 4Change limitsEdit var MAX_FILES = 12 and var MAX_MB = 5 at the top of the JS to change the maximum file count and size limit.
- 5Wire to a real upload endpointReplace fakeUpload() with a FormData POST: for each non-null file, create a FormData, append the file, and fetch("/api/upload", { method: "POST", body: formData }). Update the card chip with the response status.
- 6Export for your frameworkClick "JSX" for a React component using useState for files array and useRef for the input. Click "Vue" for a Vue 3 SFC with reactive file list.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Three events: ondragover must call e.preventDefault() to allow dropping (browsers block drop by default). ondragleave resets visual state. ondrop calls e.preventDefault() to stop the browser from navigating to the file, then reads e.dataTransfer.files — a FileList of dropped files. Convert to Array with Array.from() to use .filter() and .forEach(). To accept drops from external sources like a browser tab showing an image, also check e.dataTransfer.items[0].kind === "file" before reading the file. For improved UX, add a dragenter listener on the entire document to show a full-page drop overlay when the user drags a file anywhere over the browser window, not just the upload zone. Remove the overlay on dragleave when e.relatedTarget is null (the drag left the window entirely) or on drop.
Create a FormData object and append each file: const fd = new FormData(); files.filter(Boolean).forEach((f, i) => fd.append("file" + i, f)); Then POST: const res = await fetch("/api/upload", { method: "POST", body: fd }). No Content-Type header needed — the browser sets the correct multipart/form-data boundary automatically.
Use XMLHttpRequest instead of fetch for progress events: const xhr = new XMLHttpRequest(); xhr.upload.onprogress = e => { const pct = Math.round(e.loaded / e.total * 100); updateCardProgress(idx, pct); }; xhr.open("POST", "/api/upload"); xhr.send(formData). Track progress per card by storing the XHR in a map keyed by file index. Show a thin progress bar overlay at the bottom of each thumbnail card — set its width to pct + "%" on each onprogress event. On xhr.onload, switch the overlay to the green "done" chip. On xhr.onerror, show a red "failed" chip with a retry button that re-runs the same XHR with the original file. This pattern handles concurrent multi-file uploads because each card has its own independent XHR object.
In React, store files in const [files, setFiles] = useState([]). Use a ref on the hidden input: const inputRef = useRef(). Handle drops with onDrop on the dropzone div (add onDragOver with e.preventDefault()). Generate previews with URL.createObjectURL(file) inside useMemo or directly on add. In Vue 3, use a ref([]) for the files array and define handleDrop, handlePick, and removeFile as functions. The template uses @drop.prevent and @dragover.prevent on the dropzone div.