File drop zone with upload progress
A keyboard-accessible drag-and-drop upload area that queues files, checks size and shows a live progress bar for each upload.
Tailwind CSSUploadsuploaddrag-dropprogressfiles
Live preview
Code
<div id="drop" tabindex="0" role="button" aria-label="Choose files to upload"
class="rounded-2xl border-2 border-dashed border-slate-300 p-8 text-center transition data-[over=true]:border-indigo-500 data-[over=true]:bg-indigo-50">
<p class="font-medium text-slate-800">Drag files here or <span class="text-indigo-600 underline">browse</span></p>
<p class="mt-1 text-xs text-slate-500">Up to 50 MB each</p>
<input id="file" type="file" multiple class="sr-only" tabindex="-1">
</div>
<ul id="queue" class="mt-4 space-y-2" aria-live="polite"></ul>
const MAX = 50 * 1024 * 1024;
const drop = document.getElementById('drop');
const input = document.getElementById('file');
const queue = document.getElementById('queue');
drop.addEventListener('click', () => input.click());
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
['dragenter', 'dragover'].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.dataset.over = 'true'; }));
['dragleave', 'drop'].forEach((t) => drop.addEventListener(t, () => { drop.dataset.over = 'false'; }));
drop.addEventListener('drop', (e) => { e.preventDefault(); [...e.dataTransfer.files].forEach(upload); });
input.addEventListener('change', () => { [...input.files].forEach(upload); input.value = ''; });
function upload(file) {
const li = document.createElement('li');
li.className = 'rounded-lg border border-slate-200 p-3 text-sm';
li.innerHTML = '<div class="flex justify-between"><span class="truncate"></span><span data-pct>0%</span></div>' +
'<div class="mt-2 h-1.5 rounded-full bg-slate-100"><div data-bar class="h-full w-0 rounded-full bg-indigo-500"></div></div>';
li.querySelector('span').textContent = file.name; // textContent: never trust file names
queue.append(li);
if (file.size > MAX) { li.querySelector('[data-pct]').textContent = 'Too large'; return; }
// XHR gives real upload progress events (fetch does not)
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/uploads');
xhr.upload.onprogress = (e) => {
const pct = Math.round((e.loaded / e.total) * 100);
li.querySelector('[data-bar]').style.width = pct + '%';
li.querySelector('[data-pct]').textContent = pct + '%';
};
xhr.onload = () => { li.querySelector('[data-pct]').textContent = xhr.status < 300 ? 'Done' : 'Failed'; };
xhr.onerror = () => { li.querySelector('[data-pct]').textContent = 'Failed'; };
const body = new FormData();
body.append('file', file);
xhr.send(body);
}
How it works
What it solves
Users expect to drop files on a page and see each one move independently. A plain file input hides progress, and many drag-and-drop libraries are heavy for what is a few event handlers.
How it works
The visible zone is a focusable element with role="button" that forwards clicks and Enter or Space to a visually hidden file input, so mouse, touch and keyboard users share one path. During drag, dragenter and dragover set a data-over attribute; the Tailwind data-[over=true] variants then restyle the border and background with no class juggling. Preventing the default on dragover is what allows the drop to fire at all.
Each chosen file becomes a list row, the file name is written with textContent to avoid injecting markup, and the size is checked against a limit before any request. Uploads use XMLHttpRequest because its upload.onprogress event reports bytes sent, which fetch cannot yet do. The row bar width and percentage are updated from loaded / total, and the final status reflects the HTTP result.
Adapting it
- Point the request at your endpoint and add your CSRF header.
- Restrict types with the input
acceptattribute and re-check server side. - For very large files, replace the single request with chunked uploads.
Pitfalls
- The server must validate type and size again; client checks are advisory.
- Reset the input value after a change so choosing the same file twice still fires an event.
- Limit concurrency if users drop dozens of files at once.
How to use it
- Add the markup and script to a page that has an upload endpoint accepting multipart form data.
- Change the URL in
xhr.openand the size limit constant. - Return a success status code only once the file is stored.
- Add error messages per row if your API returns validation details.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.