File upload UIs have come a long way. The old <input type="file"> element works, but it is not very user-friendly. Modern applications let you drag files directly onto the page — and it turns out this is surprisingly straightforward to implement using the HTML5 Drag and Drop API combined with the File API.
In this tutorial, we will build a fully featured drag and drop file uploader from scratch using only vanilla JavaScript — no jQuery, no third-party libraries. By the end, we'll have a drop zone that highlights on hover, shows file previews with names and sizes, and simulates an upload with a progress bar.
Step 1: HTML Structure
Start with a minimal HTML file. We need a drop zone element and a container where we'll render file previews:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Drag & Drop Uploader</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="uploader">
<div class="drop-zone" id="drop-zone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M12 16V4m0 0L8 8m4-4 4 4M4 16v1a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-1" />
</svg>
<p>Drag & drop files here</p>
<span>or</span>
<label class="browse-btn" for="file-input">Browse files</label>
<input type="file" id="file-input" multiple hidden>
</div>
<ul class="file-list" id="file-list"></ul>
</div>
<script src="uploader.js"></script>
</body>
</html>
Step 2: Styling the Drop Zone
Create style.css. The key style is the .drop-zone--active class, which we'll toggle dynamically when a file is dragged over:
/* style.css */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.uploader { width: 100%; max-width: 540px; }
.drop-zone {
border: 2px dashed #6c63ff;
border-radius: 12px;
padding: 3rem 2rem;
text-align: center;
background: #fff;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.drop-zone svg { width: 48px; height: 48px; color: #6c63ff; margin-bottom: 1rem; }
.drop-zone p { font-size: 1.1rem; color: #333; }
.drop-zone span { display: block; margin: 0.5rem 0; color: #999; font-size: 0.9rem; }
.browse-btn {
display: inline-block;
margin-top: 0.5rem;
padding: 0.45rem 1.2rem;
background: #6c63ff;
color: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
/* Active / hover state */
.drop-zone--active {
background: #f0eeff;
border-color: #4f46e5;
}
/* File list */
.file-list { list-style: none; margin-top: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.file-item {
background: #fff;
border-radius: 10px;
padding: 0.8rem 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
box-shadow: 0 1px 4px rgba(0,0,0,.08);
}
.file-item__header { display: flex; justify-content: space-between; align-items: center; }
.file-item__name { font-weight: 500; color: #222; font-size: 0.95rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 80%; }
.file-item__size { font-size: 0.8rem; color: #999; }
.progress-bar-track { background: #e9e8ff; border-radius: 99px; height: 6px; overflow: hidden; }
.progress-bar-fill { height: 100%; background: #6c63ff; border-radius: 99px; width: 0%; transition: width 0.05s linear; }
.file-item__status { font-size: 0.8rem; color: #6c63ff; }
Step 3: Wiring Up the Drag & Drop Events
The HTML5 Drag and Drop API fires a series of events on the drop target. The most important ones are dragover (fires continuously while dragging over), dragleave, and drop. You must call event.preventDefault() in dragover to tell the browser you want to handle the drop yourself.
// uploader.js
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const fileList = document.getElementById('file-list');
// ── Drag & Drop Events ────────────────────────────────────
dropZone.addEventListener('dragover', (e) => {
e.preventDefault(); // Required — otherwise the browser opens the file
dropZone.classList.add('drop-zone--active');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drop-zone--active');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drop-zone--active');
handleFiles(e.dataTransfer.files);
});
// ── Browse Button ─────────────────────────────────────────
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
handleFiles(fileInput.files);
fileInput.value = ''; // Reset so the same file can be selected again
});
Step 4: Processing & Previewing Files
Now let's write the handleFiles function. The FileList object we receive from both dataTransfer.files and input.files contains File objects with properties like name, size, and type. We'll render each file as a list item with a simulated upload progress bar:
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function handleFiles(fileListObj) {
Array.from(fileListObj).forEach((file) => {
// Create a list item
const li = document.createElement('li');
li.className = 'file-item';
li.innerHTML = `
<div class="file-item__header">
<span class="file-item__name" title="${file.name}">${file.name}</span>
<span class="file-item__size">${formatBytes(file.size)}</span>
</div>
<div class="progress-bar-track">
<div class="progress-bar-fill"></div>
</div>
<span class="file-item__status">Uploading...</span>
`;
fileList.appendChild(li);
const fill = li.querySelector('.progress-bar-fill');
const status = li.querySelector('.file-item__status');
// Simulate an upload with a fake progress animation
simulateUpload(fill, status);
});
}
function simulateUpload(fill, status) {
let progress = 0;
const interval = setInterval(() => {
// Random increment between 5–20% per tick
progress += Math.random() * 15 + 5;
if (progress >= 100) {
progress = 100;
fill.style.width = '100%';
status.textContent = '✓ Upload complete';
status.style.color = '#22c55e';
clearInterval(interval);
} else {
fill.style.width = progress + '%';
}
}, 100);
}
In a real application, you would replace simulateUpload with an actual fetch or XMLHttpRequest call using FormData, and update the progress bar using the XMLHttpRequest.upload.onprogress event for accurate reporting.
Step 5: Real Upload with Fetch & FormData
When you're ready to connect to a real backend, replace the simulation with this pattern:
async function uploadFile(file, fill, status) {
const formData = new FormData();
formData.append('file', file);
// For progress tracking we still need XMLHttpRequest
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = (e.loaded / e.total) * 100;
fill.style.width = pct + '%';
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
status.textContent = '✓ Upload complete';
status.style.color = '#22c55e';
resolve(xhr.response);
} else {
status.textContent = '✗ Upload failed';
status.style.color = '#ef4444';
reject(new Error('Upload failed'));
}
});
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
}
The XMLHttpRequest.upload.progress event gives you real-time byte counts during the upload, which the fetch API does not yet expose natively in most browsers.
Conclusion
With just the native browser APIs — Drag and Drop, the File API, and XMLHttpRequest — you can build a polished, production-quality file uploader without reaching for a single library. Understanding these primitives also makes it much easier to integrate with component libraries like Dropzone.js or FilePond if you ever need advanced features like chunked uploads or image compression.