Source Code

<div class="wrap">
  <div class="uploader">
    <h2 class="heading">Upload Photos</h2>
    <p class="sub">Add up to 12 images. Drag &amp; 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 &mdash; 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>

Media Upload Grid — Free HTML CSS JS Snippet

Media Upload Grid · Forms · Plain HTML, CSS & JS · Live preview

What's included

Features

Drag-and-drop: onDragOver e.preventDefault() to allow drop, .active class visual feedback
URL.createObjectURL: instant local preview without upload
Hidden file input with accept="image/*" multiple for multi-select
MAX_FILES (12) and MAX_MB (5) validation with alerts
files[idx] = null pattern: preserves indices while removing individual files
Card remove animation: opacity + scale transition before DOM removal
updateFooter(): filter(Boolean).length for accurate count after removals
Per-card status chip: uploading (blue) → done (green) status pattern
auto-fill CSS Grid: thumbnails reflow naturally at any container width

About this UI Snippet

Media Upload Grid — Drag and Drop, Thumbnail Previews, and Per-File Status

Screenshot of the Media Upload Grid snippet rendered live

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You do not need to puzzle out the files[idx] = null pattern on your own. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why removeFile() sets an array slot to null instead of splicing the file out, and how updateFooter()'s files.filter(Boolean).length works around that to still report an accurate count. The same assistant can help optimize it, for instance asking whether the blob URLs created by URL.createObjectURL are ever revoked, and what happens to browser memory if a user adds and removes dozens of images without calling URL.revokeObjectURL. It is also useful for extending the uploader: ask it to replace the simulated fakeUpload() timeouts with a real XMLHttpRequest that reports per-file progress, add drag-to-reorder for the preview grid, or validate image dimensions before accepting a file. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "media upload grid" in plain HTML, CSS, and JavaScript with no libraries.

Requirements:
- A dropzone element that opens a hidden multi-file image input on click, and also accepts drag-and-drop: dragover must call preventDefault and toggle an active visual state, dragleave must remove it, and drop must call preventDefault, remove the active state, and process the dropped FileList.
- A shared file-processing function that filters incoming files to images only, enforces both a maximum total file count and a maximum size per file (rejecting oversized files with a clear message naming the offending file), and only accepts as many additional files as remain under the total cap.
- Each accepted file must be rendered immediately as a thumbnail card using a browser-generated object URL for the image source (no upload required to preview it), with a hover-revealed filename overlay and a remove button.
- Removing a file must mark that specific slot as empty in the underlying files collection without shifting or renumbering the other files' indices, animate the corresponding card out (fade and shrink) before removing it from the DOM, and update a footer count that correctly counts only the remaining non-empty slots.
- A footer must appear only once at least one file is selected, showing the live count with correct singular/plural wording, alongside a "Clear All" button that empties everything and an "Upload Files" button.
- Clicking "Upload Files" must simulate a per-card upload sequence: each card shows a distinct "uploading" status chip, then transitions to a "done" status chip after a staggered delay (not all cards finishing simultaneously), and the upload button itself must disable and relabel during the whole sequence and only re-enable once every card has finished.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Click 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.
  2. 2
    Preview 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.
  3. 3
    Clear 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).
  4. 4
    Change 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.
  5. 5
    Wire 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.
  6. 6
    Export 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

Product image upload for e-commerce listings
Use on a product creation form to let sellers upload multiple product photos. Upload each file to a cloud storage (S3, Cloudflare R2, or Supabase Storage) via a signed URL. Show the thumbnail grid so sellers can reorder images by drag-sort before publishing.
Social media post composer photo selector
Embed in a post composer to let users attach up to 4 photos. Show the thumbnail grid in the post preview. Enforce platform-specific limits (Twitter: 4, Instagram: 10) by adjusting MAX_FILES. Validate aspect ratios (1:1, 16:9, 4:5) with a canvas check after selection.
Support ticket attachment uploader
Let users attach screenshots to bug reports or support tickets. Upload to a temporary storage endpoint and attach the returned URLs to the ticket form submission. Show upload progress on each thumbnail with a linear progress bar overlay instead of the status chip.
Upload to S3 with signed URLs from a backend API
GET /api/upload-url?count=N from your backend to receive N pre-signed S3 PUT URLs. For each file, PUT directly to the S3 URL from the browser: fetch(signedUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } }). No CORS setup on your API server — the PUT goes directly to S3.
Study drag-and-drop file handling and blob URLs
The snippet teaches three key APIs: the DataTransfer API (e.dataTransfer.files in onDrop), URL.createObjectURL for local preview, and the FileList API (file.name, file.size, file.type). These are the foundations of any file upload UI without a library.
Photo gallery builder with client-side preview
Extend for a photo album builder: add a caption input per card (shown on hover), a reorder drag handle, and a "Set as cover" button for the first image. The client-side preview means users see the full gallery layout before any upload happens.
Related: Box Shadow Generator
See the Box Shadow Generator for a related forms pattern worth pairing with this one.

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.