Forms & Input

Copy. Paste. Ship.

Home › Code Snippets › HTML › Download Button

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

📄 PDF · 2.4 MB · Updated Oct 2026

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

1. Why not just use <a download>?

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.

2. fetch() with streaming reader

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.

3. Building the file from chunks

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.

4. Cleanup: URL.revokeObjectURL

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.

5. Progress bar via transform: scaleX()

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.

6. Four states, one element

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.

Content-Length missing

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.

CORS blocks the fetch

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.

Large files exhaust memory

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

User clicks again during download

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.

Download starts before progress ends

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 and browser history

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.

No error handling

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.

No file size shown

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 announcedrole="status" aria-live="polite"Screen reader hears percentage
Completion announcedStatus update on successUser knows file arrived
File size labeledVisible textUsers 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~2KBNo frameworks
JS size~50 linesFetch + Blob + state
Progress animationGPUtransform: scaleX()
Memory1× file sizeChunks 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

❓ 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.

🔗 Related snippets

🌐 External resources

← All HTML Snippets