Part of our complete guide: HTML & Form Snippets →
UI Component · HTML + CSS + JS
Download Button
Button with four states — idle, downloading (with progress bar), success, and error. Copy-paste ready, works with real files.
Last updated: October 2026 · 8 min read
A download button with progress tracks a file download in real time. Use fetch() with a ReadableStream reader to track bytes, then create a Blob URL to trigger the browser download. Show progress via a background scaleX() transform.
What you get: Complete download button with idle state, progress animation, success checkmark, error state, file size display, and retry support. Works with any file URL.
Why download buttons need four states
A download that takes 3 seconds feels broken if nothing changes on the button. Users click again. And again. Then they refresh the page and restart. The browser sends the request 5 times for one file. This happens because the button gives no feedback — it looks identical before and after the click.
Four states solve it. Idle is the button before click. Downloading shows a progress bar filling up — the user sees movement, knows the file is coming. Success is a green checkmark — the user knows it's done, doesn't click again. Error is red with a retry button — if the network fails, the user isn't left guessing.
The technical part is trickier than it looks. The native <a download> attribute works — the browser handles the download — but exposes no progress events. You can't track bytes. That's why real progress requires fetch(): download the file to memory, track the stream, then trigger the actual download with a Blob URL. The trade-off: it doubles memory usage for the file. For files under 20MB, that's fine. For larger files, either skip the progress bar or use a streaming download to disk (not possible in the browser yet).
🎬 Live Download Button
Click to see all four states. The demo simulates progress (no real file).
📄 Project Guide
Complete developer's handbook · PDF
Complete code
<button class="dl-btn" id="dlBtn" type="button">
<div class="dl-progress-bg" id="dlProgress"></div>
<div class="dl-content" id="dlContent">
<span class="dl-icon">⬇️</span>
<span id="dlText">Download PDF</span>
</div>
</button>
<style>
.dl-btn {
position: relative;
display: inline-flex; align-items: center; justify-content: center;
gap: 0.5rem;
min-width: 220px;
background: #3b82f6; color: #fff;
border: none; padding: 0.85rem 1.5rem;
border-radius: 0.6rem;
font-size: 0.9rem; font-weight: 700;
font-family: inherit; cursor: pointer;
transition: background 0.2s, transform 0.1s;
overflow: hidden; user-select: none;
}
.dl-btn:hover:not(:disabled) { background: #2563eb; }
.dl-btn:active:not(:disabled) { transform: scale(0.98); }
.dl-btn:disabled { cursor: default; }
.dl-btn.downloading {
background: #1e293b;
border: 1px solid #3b82f6;
color: #60a5fa;
}
.dl-btn.success { background: #16a34a; }
.dl-btn.success:hover { background: #16a34a; }
.dl-btn.error { background: #dc2626; }
.dl-progress-bg {
position: absolute; inset: 0;
background: rgba(59, 130, 246, 0.15);
transform-origin: left;
transform: scaleX(0);
transition: transform 0.15s linear;
z-index: 0;
}
.dl-btn.downloading .dl-progress-bg {
background: rgba(59, 130, 246, 0.25);
}
.dl-content {
position: relative; z-index: 1;
display: inline-flex; align-items: center;
gap: 0.5rem;
}
.dl-icon {
display: inline-block;
transition: transform 0.2s;
}
.dl-btn.downloading .dl-icon {
animation: bounce 0.6s infinite alternate;
}
@keyframes bounce {
from { transform: translateY(0); }
to { transform: translateY(2px); }
}
.dl-spinner {
display: inline-block;
width: 16px; height: 16px;
border: 2px solid rgba(96, 165, 250, 0.3);
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<script>
const dlBtn = document.getElementById('dlBtn');
const dlProgress = document.getElementById('dlProgress');
const dlContent = document.getElementById('dlContent');
const dlText = document.getElementById('dlText');
async function downloadFile(url, filename) {
// Set downloading state
dlBtn.className = 'dl-btn downloading';
dlBtn.disabled = true;
dlContent.innerHTML = '<span class="dl-spinner"></span> Downloading...';
try {
// Fetch with progress tracking
const response = await fetch(url);
if (!response.ok) throw new Error('Download failed');
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (total > 0) {
const percent = (received / total) * 100;
dlProgress.style.transform = `scaleX(${percent / 100})`;
dlContent.innerHTML = `<span class="dl-spinner"></span> ${Math.round(percent)}%`;
}
}
// Create blob and trigger download
const blob = new Blob(chunks);
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
// Success state
dlBtn.className = 'dl-btn success';
dlContent.innerHTML = '<span>✅</span> Downloaded!';
// Reset after 3 seconds
setTimeout(() => {
dlBtn.className = 'dl-btn';
dlBtn.disabled = false;
dlContent.innerHTML = '<span class="dl-icon">⬇️</span> <span id="dlText">Download PDF</span>';
dlProgress.style.transform = 'scaleX(0)';
}, 3000);
} catch (err) {
// Error state
dlBtn.className = 'dl-btn error';
dlContent.innerHTML = '<span>❌</span> Failed — Retry';
dlBtn.disabled = false;
dlProgress.style.transform = 'scaleX(0)';
// Reset on click
dlBtn.onclick = () => {
dlBtn.className = 'dl-btn';
dlContent.innerHTML = '<span class="dl-icon">⬇️</span> <span id="dlText">Download PDF</span>';
dlBtn.onclick = () => downloadFile(url, filename);
};
}
}
dlBtn.addEventListener('click', () => {
downloadFile('/files/project-guide.pdf', 'project-guide.pdf');
});
</script>
How the download button works
The native <a href="file.pdf" download> is the simplest solution and works everywhere. But it exposes no progress events — the browser downloads the file silently and saves it. You can't show a progress bar, can't detect errors, can't display "downloading..." state. For simple text files, that's fine. For anything users care about, you need JS control.
The fetch() API returns a ReadableStream. Calling response.body.getReader() gives you chunks as they arrive. Each chunk's length is how many bytes it contains. Summing them gives you progress. The Content-Length header tells you the total — divided, that's your percentage.
You store every chunk in an array as it arrives. When the stream is done, new Blob(chunks) combines them into one file object. You create a temporary URL with URL.createObjectURL(blob) and programmatically click an anchor with that URL. The browser downloads the blob as a normal file.
Blob URLs hold the file in memory. If you don't revoke them, memory leaks. After the anchor click, call URL.revokeObjectURL(blobUrl). This frees the blob — the browser has already started the actual download to disk.
The progress bar is an absolutely positioned div spanning the button, with transform-origin: left. As progress increases, transform: scaleX(0.5) fills it 50%. Using transform (not width) means it's GPU-accelerated — smooth even at 60fps. Width animation would cause layout recalculation on every update.
The button changes its className between dl-btn, dl-btn downloading, dl-btn success, and dl-btn error. Each state has different colors, icons, and text. No DOM replacement — just CSS class changes. This preserves focus, layout, and any event listeners.
🔬 Edge cases
The problems that break download buttons in production.
Some servers don't send the Content-Length header (gzipped responses, streaming APIs). Progress falls back to 0 — you know bytes arrived but not the percentage. Fix: check response.headers.get('content-length'); if null, show "Downloading..." without a percentage, then success when done.
Fetching a file from a different domain requires CORS headers on the server. If the server doesn't set them, the fetch fails (even though a normal <a download> would work). Fix: either proxy the file through your own server, or accept that cross-origin files can't show real progress — use the native anchor instead.
All chunks are held in RAM until the download completes. A 500MB file doubles memory usage (once in chunks, once in Blob). Fix: for files over 20MB, skip the fetch approach — use the native <a download> and accept no progress bar. Or offer two buttons: "quick download" (native) and "download with progress" (fetch).
Multiple downloads, multiple Blob URLs, multiple memory leaks. Fix: set dlBtn.disabled = true during download. Re-enable only after success or error. This is what the code above does — the second click is a no-op.
If you trigger the anchor click immediately after reader.read() returns done, the button might already show "Downloaded!" while the browser is still writing the file to disk. Fix: the visual state is "downloaded to memory" — the browser then saves it. For accuracy, say "Preparing download..." then "Saved!" after 500ms.
Blob URLs are temporary. If a user opens the download link in a new tab (right-click → open in new tab), it might work once then fail. Fix: always trigger via programmatic link.click() — never expose the blob URL to the user.
Network fails mid-download, response is 500, file doesn't exist — the button stays stuck on "Downloading 47%" forever. Fix: wrap the entire flow in try/catch. Show error state with retry button. Log the error to console for debugging.
Users on mobile data want to know what they're downloading. Fix: show file size below the button ("PDF · 2.4 MB"). This reduces abandoned downloads and builds trust.
♿ Accessibility
Download buttons fail accessibility when the progress state isn't announced. Screen reader users click, hear nothing, and think the button is broken.
| Requirement | Implementation | Why |
|---|---|---|
| Button semantics | <button type="button"> | Prevents accidental form submit |
| Progress announced | role="status" aria-live="polite" | Screen reader hears percentage |
| Completion announced | Status update on success | User knows file arrived |
| File size labeled | Visible text | Users know what they're downloading |
Add aria-live="polite" to a visually-hidden status element that updates as the download progresses. Without it, screen reader users have no idea the download is happening. Update it every 25% (not every chunk) to avoid announcement spam.
⚡ Performance
| Aspect | Value | Notes |
|---|---|---|
| CSS size | ~2KB | No frameworks |
| JS size | ~50 lines | Fetch + Blob + state |
| Progress animation | GPU | transform: scaleX() |
| Memory | 1× file size | Chunks held until complete |
Skip fetch for large files. The fetch approach doubles memory for the file (chunks + blob). For any file over 20MB, use native <a download> instead — no progress, but no memory risk either. The best compromise: show "Downloading..." with a spinner for the first 500ms, then switch to a fake indeterminate progress bar. Users see activity without the memory cost.
⚠️ Common mistakes
- Not disabling the button during download. Multiple fetches, memory leaks, duplicate downloads.
- Forgetting
URL.revokeObjectURL. Memory leak — blob stays in RAM forever. - Animating
widthfor the progress bar. Layout cost. Usetransform: scaleX(). - No error handling. Failed download leaves button stuck on "Downloading...".
- Assuming
Content-Lengthexists. Gzipped responses often don't include it. - Using fetch for cross-origin files. CORS blocks them — native anchor works, fetch doesn't.
- No success state. User doesn't know if it worked, clicks again, re-downloads.
- No file size shown. Users abandon downloads when they don't know the size.
❓ Frequently asked questions
How do I create a download button in HTML?
Use an anchor element with the download attribute: <a href='file.pdf' download>Download</a>. The download attribute tells the browser to save the file instead of navigating to it. Add styling with CSS for the button appearance.
How do I add a progress bar to a download button?
Use fetch() with a ReadableStream reader. Track bytes received and total bytes from the Content-Length header. Update a CSS width property on an inner div to show progress. Add transitions for smooth animation.
Why doesn't the progress bar work with a normal link?
Native <a download> doesn't expose progress events — the browser handles the download and you can't track it. To show progress, you must fetch the file with JavaScript, track the stream, and create a blob URL for the browser to download.
What does the download attribute do in HTML?
The download attribute on an anchor tells the browser to save the linked file instead of navigating to it. You can optionally pass a filename: <a href='file.pdf' download='report.pdf'>. Only works for same-origin URLs.
Can I show download progress without JavaScript?
No for real file downloads. You can show a fake progress animation with pure CSS, but it won't reflect actual download progress. For real progress, JavaScript is required.
How do I make a download button accessible?
Use a <button> or <a> with clear text. Add aria-label if the visible text is ambiguous. For progress, use role='progressbar' with aria-valuenow, aria-valuemin, and aria-valuemax. Announce completion with role='status'.
What happens if the download fails mid-way?
The progress bar should stop and show an error state. Offer a retry button. If using fetch(), catch errors and display them. If using native <a download>, the browser shows its own error — you have no control.
Should I show file size before download?
Yes. Show the file size (e.g., 'PDF · 2.4 MB') on the button or below it. Users want to know what they're downloading, especially on mobile data. This also reduces abandoned downloads.