Download Button

A download button that fetches the file as a Blob via the Fetch API and triggers a native browser download. Cycles through idle, downloading, ready, and fallback states with CSS-animated icon transitions. Falls back to a direct link if CORS blocks the fetch.

javascriptdownloadfetchblobanimationaccessibility

Code

index.html
html
<button data-download-src="https://vz-6ed806ff-5e5.b-cdn.net/9e75ac7b-ca03-4b9e-a472-0898d863593d/playlist.m3u8" data-download-name="osmo-logo.jpg" class="download-btn">
  <span data-download-icon-wrap="" class="download-btn__icon-hold">
    <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 16 16" fill="none" data-download-icon="" class="download-btn__icon">
      <g clip-path="url(#clip0_1363_5581)">
        <path d="M8.74902 10.7734L12.4688 7.05371L13.5293 8.11426L7.99902 13.6445L2.46875 8.11426L3.5293 7.05371L7.24902 10.7734V0.0830078H8.74902V10.7734Z" fill="currentColor" data-download-arrow=""></path>
        <path d="M15.5 14.75V16.25H0.5V14.75H15.5Z" fill="currentColor" data-download-base=""></path>
      </g>
    </svg>
    <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 20 20" fill="none" data-download-success="" class="download-btn__icon is--success">
      <path d="M2 9.5L8 15.5L19 4.5" stroke="currentColor" stroke-width="1.75" stroke-miterlimit="10" stroke-dasharray="25" stroke-dashoffset="25"></path>
    </svg>
  </span>
  <span data-download-label="" class="download-btn__label">Download</span>
</button>
styles.css
css
.download-btn {
  grid-column-gap: .625em;
  grid-row-gap: .625em;
  color: #f2f2f2;
  background-color: #0065e1;
  border-radius: .5em;
  justify-content: center;
  align-items: center;
  padding: .75em 1.5em .75em 1em;
  display: flex;
}

.download-btn__label {
  font-size: 1.5em;
  font-weight: 500;
  line-height: 1.2;
}

.download-btn__icon-hold {
  background-color: #fff3;
  border-radius: 100em;
  flex: none;
  justify-content: center;
  align-items: center;
  width: 2.5em;
  height: 2.5em;
  padding: 0;
  display: flex;
}

.download-btn__icon {
  justify-content: center;
  align-items: center;
  width: 1em;
  height: 1em;
  display: flex;
  overflow: visible !important;
}

.download-btn__icon.is--success {
  position: absolute;
}

/* Transition settings */
[data-download-src] {
  transition: 0.25s background-color ease;
}

[data-download-arrow],
[data-download-base] {
  transition: 0.5s transform cubic-bezier(0.625, 0.05, 0, 1);
}

[data-download-icon-wrap] {
  clip-path: inset(0em round 100em);
  transition: 0.5s clip-path cubic-bezier(0.625, 0.05, 0, 1);
}

[data-download-success] path {
  transition: 0.4s stroke-dashoffset cubic-bezier(0.625, 0.05, 0, 1);
}

[data-download-base] {
  transform-origin: center center;
}

/* When status is 'downloading' */
[data-download-src][data-download-state="downloading"] {
  pointer-events: none;
}

body:has([data-download-src][data-download-state="downloading"]) {
  cursor: waiting;
}

/* When status is 'ready' or 'fallback' */
[data-download-src][data-download-state="ready"] [data-download-icon-wrap] {
  transition-delay: 0.15s;
  clip-path: inset(0.35em round 100em);
}

[data-download-src][data-download-state="ready"] [data-download-arrow] {
  transform: translate(0px, 200%);
}

[data-download-src][data-download-state="ready"] [data-download-base] {
  transition-delay: 0.1s;
  transform: scale(0, 1);
}

[data-download-src][data-download-state="ready"] [data-download-success] path {
  transition-delay: 0.25s;
  stroke-dashoffset: 0;
}

/* Hover state */
@media (hover: hover) and (pointer: fine) {
  [data-download-src]:hover {
    background-color: #0a75f8;
  }

  [data-download-src][data-download-state="idle"]:hover [data-download-arrow] {
    transform: translate(0px, -30%);
  }

  [data-download-src][data-download-state="idle"]:hover [data-download-base] {
    transform: scale(1.2, 1);
  }
}

/* Focus state */
[data-download-src]:focus {
  background-color: #0a75f8;
}

[data-download-src][data-download-state="idle"]:focus [data-download-arrow] {
  transform: translate(0px, -30%);
}

[data-download-src][data-download-state="idle"]:focus [data-download-base] {
  transform: scale(1.2, 1);
}
script.js
javascript
function initDownloadButtons() {
  const selector = '[data-download-src]';
  const attrSrc = 'data-download-src';
  const attrName = 'data-download-name';

  const setState = (el, state) => {
    el.dataset.downloadState = state; // "idle" | "downloading" | "ready" | "fallback"
  };

  const triggerDownload = (url, filename) => {
    const a = document.createElement('a');
    a.href = url;
    if (filename) a.download = filename;
    a.rel = 'noopener';
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
  };

  document.querySelectorAll(selector).forEach(el => {
    setState(el, 'idle');

    // Optional label inside the button
    const labelEl = el.querySelector('[data-download-label]');
    if (labelEl && !labelEl.dataset.downloadOriginalLabel) {
      labelEl.dataset.downloadOriginalLabel = labelEl.textContent;
    }

    const showSuccessAndReset = () => {
      if (labelEl) {
        const successText = labelEl.getAttribute('data-download-success');
        if (successText) {
          labelEl.textContent = successText;
        }
      }

      if (el._downloadResetTimeout) {
        clearTimeout(el._downloadResetTimeout);
      }

      el._downloadResetTimeout = setTimeout(() => {
        setState(el, 'idle');

        if (labelEl) {
          const original = labelEl.dataset.downloadOriginalLabel;
          if (original != null) {
            labelEl.textContent = original;
          }
        }
      }, 3000);
    };

    el.addEventListener('click', async (e) => {
      e.preventDefault();

      if (el.dataset.downloadState === 'downloading') return;

      const src = el.getAttribute(attrSrc);
      if (!src) return;

      const customName = el.getAttribute(attrName);

      // derive filename from URL if no explicit name
      const urlObj = new URL(src, window.location.href);
      const urlFilePart = urlObj.pathname.split('/').pop() || 'download';
      const fileName = customName || urlFilePart;

      el.blur();

      try {
        setState(el, 'downloading');

        const res = await fetch(src, { mode: 'cors', credentials: 'omit' });
        if (!res.ok) throw new Error('bad status');

        const blob = await res.blob();
        const objectUrl = URL.createObjectURL(blob);

        setState(el, 'ready');
        triggerDownload(objectUrl, fileName);
        showSuccessAndReset();

        setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
      } catch (err) {
        // CORS / network fallback → plain link download
        setState(el, 'fallback');
        triggerDownload(src, fileName);
        showSuccessAndReset();
      }
    });
  });
}

// Initialize Download Button
document.addEventListener("DOMContentLoaded", function () {
  initDownloadButtons();
});

Guide

data-download-src

Required on the button element. Defines the URL to fetch and download. This attribute also registers the element as a download trigger.

data-download-name

Optional. Sets a custom filename for the downloaded file. If omitted, the filename is extracted from the URL path automatically.

Download States

data-download-state cycles automatically: "idle" (ready), "downloading" (fetch in progress — pointer-events disabled, cursor set to waiting), "ready" (download triggered, success animation plays), "fallback" (CORS failed, direct link used). All visual states are driven by CSS attribute selectors.

data-download-label

Optional. Add to a text element inside the button. The script stores its original text and can replace it with a success message if data-download-success is set on the label element.

Success Animation

The checkmark SVG uses stroke-dasharray and stroke-dashoffset to draw in when the ready state is applied. The download arrow exits downward, the base scales out, then the icon container clips in — all sequenced with transition-delay values in CSS.

CORS Fallback

If the fetch fails due to CORS or network issues, the button switches to "fallback" state and triggers a plain anchor click on the original URL. To avoid this, ensure your file host includes the served extension in its CORS-allowed list, or host files on Bunny CDN / Webflow Asset Manager.