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.
Code
<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>.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);
}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.