Media Upload Grid — Free HTML CSS JS Snippet

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

Share & Support

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.

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.

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.

Media Upload Grid — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Media Upload Grid</title>
  <style>
    * { 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; }
  </style>
</head>
<body>
  <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>
  <script>
    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">&#215;</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);
    }
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Media Upload Grid</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      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;
    }

    .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);
    }

    .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;
    }

    .btn-upload:disabled {
      background: #e2e8f0; color: #94a3b8; cursor: not-allowed;
    }
  </style>
</head>
<body>
  <div class="w-full max-w-[640px]">
    <div class="bg-[#fff] rounded-[20px] shadow-[0_4px_24px_rgba(0,0,0,0.07)] p-7">
      <h2 class="text-lg font-extrabold text-[#0f172a] mb-1">Upload Photos</h2>
      <p class="text-[13px] text-[#64748b] mb-5">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="text-sm text-[#475569] mt-3 mx-0 mb-1.5">Drop images here or <span class="text-[#6366f1] font-bold underline">click to upload</span></p>
        <p class="text-xs text-[#94a3b8]">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 grid-cols-[repeat(auto-fill,_minmax(130px,_1fr))] gap-2.5 mt-4" id="previewGrid"></div>
      <div class="footer" id="footer" style="display:none">
        <span id="fileCount">0 files selected</span>
        <div class="flex gap-2">
          <button class="bg-transparent border border-[#e2e8f0] text-[#64748b] py-2 px-4 rounded-lg text-[13px] cursor-pointer [transition:all_0.15s] hover:bg-[#f8fafc] hover:text-[#1e293b]" onclick="clearAll()">Clear All</button>
          <button class="btn-upload bg-[#6366f1] text-[#fff] border-0 py-2 px-5 rounded-lg text-[13px] font-bold cursor-pointer [transition:background_0.15s] hover:bg-[#4f46e5]" onclick="fakeUpload()">Upload Files</button>
        </div>
      </div>
    </div>
  </div>
  <script>
    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">&#215;</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);
    }
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to MediaUploadGrid.module.css
const css = `
* { 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; }
`;

export default function MediaUploadGrid() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    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">&#215;</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);
    }
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };

    // Expose handlers used by inline JSX events to the global scope
    if (typeof triggerInput === 'function') window.triggerInput = triggerInput;
    if (typeof onDragOver === 'function') window.onDragOver = onDragOver;
    if (typeof onDragLeave === 'function') window.onDragLeave = onDragLeave;
    if (typeof onDrop === 'function') window.onDrop = onDrop;
    if (typeof onFilePick === 'function') window.onFilePick = onFilePick;
    if (typeof clearAll === 'function') window.clearAll = clearAll;
    if (typeof fakeUpload === 'function') window.fakeUpload = fakeUpload;
    if (typeof removeFile === 'function') window.removeFile = removeFile;
  }, []);

  return (
    <>
      <style>{css}</style>
      <div className="wrap">
        <div className="uploader">
          <h2 className="heading">Upload Photos</h2>
          <p className="sub">Add up to 12 images. Drag &amp; drop or click to browse.</p>
          <div className="dropzone" id="dropzone" onClick={(event) => { triggerInput() }} onDragOver={(event) => { onDragOver(event) }} onDragLeave={(event) => { onDragLeave(event) }} onDrop={(event) => { onDrop(event) }}>
            <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="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 className="dz-text">Drop images here or <span className="dz-link">click to upload</span></p>
            <p className="dz-hint">JPG, PNG, GIF, WebP &mdash; max 5MB each</p>
          </div>
          <input type="file" id="fileInput" accept="image/*" multiple style={{ display: 'none' }} onChange={(event) => { onFilePick(event) }} />
          <div className="grid" id="previewGrid"></div>
          <div className="footer" id="footer" style={{ display: 'none' }}>
            <span id="fileCount">0 files selected</span>
            <div className="footer-btns">
              <button className="btn-clear" onClick={(event) => { clearAll() }}>Clear All</button>
              <button className="btn-upload" onClick={(event) => { fakeUpload() }}>Upload Files</button>
            </div>
          </div>
        </div>
      </div>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function MediaUploadGrid() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    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">&#215;</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);
    }
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };

    // Expose handlers used by inline JSX events to the global scope
    if (typeof triggerInput === 'function') window.triggerInput = triggerInput;
    if (typeof onDragOver === 'function') window.onDragOver = onDragOver;
    if (typeof onDragLeave === 'function') window.onDragLeave = onDragLeave;
    if (typeof onDrop === 'function') window.onDrop = onDrop;
    if (typeof onFilePick === 'function') window.onFilePick = onFilePick;
    if (typeof clearAll === 'function') window.clearAll = clearAll;
    if (typeof fakeUpload === 'function') window.fakeUpload = fakeUpload;
    if (typeof removeFile === 'function') window.removeFile = removeFile;
  }, []);

  return (
    <>
      <style>{`
* {
  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;
}

.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);
}

.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;
}

.btn-upload:disabled {
  background: #e2e8f0; color: #94a3b8; cursor: not-allowed;
}
      `}</style>
      <div className="w-full max-w-[640px]">
        <div className="bg-[#fff] rounded-[20px] shadow-[0_4px_24px_rgba(0,0,0,0.07)] p-7">
          <h2 className="text-lg font-extrabold text-[#0f172a] mb-1">Upload Photos</h2>
          <p className="text-[13px] text-[#64748b] mb-5">Add up to 12 images. Drag &amp; drop or click to browse.</p>
          <div className="dropzone" id="dropzone" onClick={(event) => { triggerInput() }} onDragOver={(event) => { onDragOver(event) }} onDragLeave={(event) => { onDragLeave(event) }} onDrop={(event) => { onDrop(event) }}>
            <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="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 className="text-sm text-[#475569] mt-3 mx-0 mb-1.5">Drop images here or <span className="text-[#6366f1] font-bold underline">click to upload</span></p>
            <p className="text-xs text-[#94a3b8]">JPG, PNG, GIF, WebP &mdash; max 5MB each</p>
          </div>
          <input type="file" id="fileInput" accept="image/*" multiple style={{ display: 'none' }} onChange={(event) => { onFilePick(event) }} />
          <div className="grid grid-cols-[repeat(auto-fill,_minmax(130px,_1fr))] gap-2.5 mt-4" id="previewGrid"></div>
          <div className="footer" id="footer" style={{ display: 'none' }}>
            <span id="fileCount">0 files selected</span>
            <div className="flex gap-2">
              <button className="bg-transparent border border-[#e2e8f0] text-[#64748b] py-2 px-4 rounded-lg text-[13px] cursor-pointer [transition:all_0.15s] hover:bg-[#f8fafc] hover:text-[#1e293b]" onClick={(event) => { clearAll() }}>Clear All</button>
              <button className="btn-upload bg-[#6366f1] text-[#fff] border-0 py-2 px-5 rounded-lg text-[13px] font-bold cursor-pointer [transition:background_0.15s] hover:bg-[#4f46e5]" onClick={(event) => { fakeUpload() }}>Upload Files</button>
            </div>
          </div>
        </div>
      </div>
    </>
  );
}
Vue
<template>
  <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" @click="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" @change="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" @click="clearAll()">Clear All</button>
          <button class="btn-upload" @click="fakeUpload()">Upload Files</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { onMounted } from 'vue';

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">&#215;</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);
}

onMounted(() => {
  if (typeof removeFile === 'function') window.removeFile = removeFile;
});
</script>

<style scoped>
* { 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; }
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-media-upload-grid',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <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" (click)="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" (change)="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" (click)="clearAll()">Clear All</button>
            <button class="btn-upload" (click)="fakeUpload()">Upload Files</button>
          </div>
        </div>
      </div>
    </div>
  `,
  styles: [`
    * { 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; }
  `]
})
export class MediaUploadGridComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    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">&#215;</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);
    }

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { triggerInput, onDragOver, onDragLeave, onDrop, onFilePick, addFiles, renderCard, removeFile, updateFooter, clearAll, fakeUpload });

    // Expose handlers used by runtime-injected inline markup to window
    if (typeof removeFile === 'function') window.removeFile = removeFile;
  }
}