More Buttons Snippets
Download Button — Progress Ring HTML CSS JS Snippet
Download Button · Buttons · Plain HTML, CSS & JS · Live preview
What's included
Features
stroke-dashoffset is updated every requestAnimationFrame rather than via CSS transition, so the fill is smooth and export-safe.stroke-dasharray = circumference and a shrinking offset to draw the arc — the standard SVG progress technique.-90deg so progress begins at 12 o'clock, the conventional direction users expect.1 - (1 - p)²) makes the ring slow near the end, feeling like a real transfer instead of a robotic linear fill.p, so they can never drift out of agreement.startDownload ignores clicks while loading or done, so rapid clicks cannot stack or restart the animation.finish shows success, then resets the ring and returns to idle after a pause, re-arming the button.About this UI Snippet
Download Button — Idle → Progress Ring → Success State Machine

A download (or upload, or save) button that just sits there during a transfer feels broken. The polished version turns the click into a visible journey: the label gives way to a filling progress ring with a live percentage, and on completion it confirms with a checkmark. This snippet implements that micro-interaction in plain HTML, CSS, and vanilla JavaScript: a three-state button driven by a JavaScript-animated SVG ring, an eased percentage, and an automatic reset.
Three states, cross-faded in place
The button holds three layers — idle (icon + "Download"), loading (ring + percentage), and done (check + "Saved") — stacked absolutely and toggled by classes on the button. Switching states cross-fades the layers with opacity and a small vertical slide; the button keeps a fixed size, so there is no width-morph jank and the animation is purely opacity/transform, which exports cleanly to utility frameworks. The button background also shifts colour per state (indigo → deep indigo → green) to reinforce the change.
JS-driven SVG progress ring
The ring is two SVG circles: a faint track and a progress stroke. The progress is drawn with the stroke-dash technique — stroke-dasharray set to the circumference (2πr) and stroke-dashoffset reduced toward zero to "draw" the arc. Rather than a CSS transition (which utility frameworks do not reliably apply to stroke-dashoffset), the offset is updated every frame in a requestAnimationFrame loop, so the fill is smooth and fully under your control. The SVG is rotated -90deg so the ring starts filling from the top, the conventional direction.
Eased, realistic progress
A linear fill looks robotic. The loop applies an ease-out curve (1 - (1 - p)²) so the ring races ahead early and slows near the end — closer to how a real transfer behaves. The percentage text updates in lockstep with the ring from the same p value, so the number and the arc never disagree.
Re-entrancy guard and auto-reset
startDownload ignores clicks while the button is already loading or done, so spamming it cannot stack animations or restart mid-fill. On completion, finish switches to the success state and, after a short pause, resets the ring and returns to idle so the button is ready for another download.
The 2.2-second timer stands in for a real transfer — replace it by driving setProgress from a fetch with a ReadableStream reader or an XMLHttpRequest progress event. Pair this with a loading button for generic async actions, an upload progress component, or an add to cart button micro-interaction.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA purple "Download" button with a download icon appears on a dark background.
- 2Click itThe label cross-fades to a progress ring that fills from the top while a percentage counts up beside it.
- 3Watch the easingThe ring races ahead early and eases as it nears 100%, mimicking a real transfer rather than a linear bar.
- 4See the success stateAt 100% the button turns green and shows a checkmark with "Saved".
- 5It auto-resetsAfter about two seconds it fades back to the idle "Download" state, ready to run again.
- 6Wire a real transferReplace the timer by calling
setProgressfrom a fetch stream or XHRprogressevent with the real loaded/total ratio.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Use fetch and read the response body as a stream: get Content-Length for the total, then read chunks from response.body.getReader(), accumulating bytes and calling setProgress(received / total) as they arrive. Or with XMLHttpRequest, listen to the progress event and call setProgress(e.loaded / e.total). Call finish() when the stream completes.
For a real transfer you set the exact progress value as data arrives, which is inherently JS-driven. Even for the simulated version, utility frameworks (Tailwind) do not include stroke-dashoffset in their transition utility, so a CSS-transition approach would snap in the React + Tailwind export. Updating the offset each requestAnimationFrame is smooth and works identically everywhere.
Add an error state class with a red background and an alert icon. In your fetch/XHR error or non-OK-status handler, cancel the animation frame, switch the button to the error state with a "Retry" affordance, and clear the loading class. Keep the re-entrancy guard so a failed attempt can be retried cleanly once reset.
The dash length is the circle's circumference, 2 × π × r. Set both stroke-dasharray and the initial stroke-dashoffset to that value, then reduce the offset toward 0 as progress goes 0 → 1. If you change the circle's r, recompute C in the JS (the snippet derives it from r = 9) and update the CSS stroke-dasharray to match.
In React, hold a status ('idle' | 'loading' | 'done') and progress in useState, render the ring offset from progress, and drive it from your fetch/XHR handler (cancel any rAF in a cleanup). In Vue, use refs and :style for the offset. In Angular, track status/progress on the component and bind [style.strokeDashoffset]. The stroke-dash math and state CSS port unchanged.
Download Button — Export as HTML, React, Vue, Angular & Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Download Button</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:24px}
.dl-stage{text-align:center}
.dl-btn{position:relative;min-width:180px;height:52px;border:none;border-radius:14px;background:#6366f1;color:#fff;font-family:inherit;font-size:15px;font-weight:700;cursor:pointer;overflow:hidden;transition:background .3s}
.dl-btn:hover{background:#4f46e5}
.dl-btn:active{transform:scale(.98)}
.dl-btn.loading{background:#4338ca;cursor:default}
.dl-btn.done{background:#10b981;cursor:default}
.dl-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:8px;opacity:0;transform:translateY(8px);transition:opacity .3s,transform .3s}
.dl-btn .dl-idle{opacity:1;transform:translateY(0)}
.dl-btn.loading .dl-idle{opacity:0;transform:translateY(-8px)}
.dl-btn.loading .dl-loading{opacity:1;transform:translateY(0)}
.dl-btn.done .dl-idle{opacity:0}
.dl-btn.done .dl-done{opacity:1;transform:translateY(0)}
.dl-ring{transform:rotate(-90deg)}
.dl-track{fill:none;stroke:rgba(255,255,255,.25);stroke-width:2.5}
.dl-prog{fill:none;stroke:#fff;stroke-width:2.5;stroke-linecap:round;stroke-dasharray:56.5;stroke-dashoffset:56.5}
.dl-pct,#dlPct{font-variant-numeric:tabular-nums;font-size:14px;font-weight:700;min-width:34px;text-align:left}
.dl-hint{font-size:12px;color:#64748b}
</style>
</head>
<body>
<div class="dl-stage">
<button class="dl-btn" id="dlBtn" onclick="startDownload()">
<span class="dl-state dl-idle">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
Download
</span>
<span class="dl-state dl-loading">
<svg class="dl-ring" viewBox="0 0 24 24" width="20" height="20">
<circle class="dl-track" cx="12" cy="12" r="9"/>
<circle class="dl-prog" id="dlProg" cx="12" cy="12" r="9"/>
</svg>
<span id="dlPct">0%</span>
</span>
<span class="dl-state dl-done">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
Saved
</span>
</button>
<p class="dl-hint">Click to download · progress ring fills, then confirms</p>
</div>
<script>
var C = 2 * Math.PI * 9;
var btn = document.getElementById('dlBtn');
var prog = document.getElementById('dlProg');
var pct = document.getElementById('dlPct');
var raf = null;
function setProgress(p) {
prog.style.strokeDashoffset = (C * (1 - p)).toFixed(2);
pct.textContent = Math.round(p * 100) + '%';
}
function startDownload() {
if (btn.classList.contains('loading') || btn.classList.contains('done')) return;
btn.classList.add('loading');
setProgress(0);
var start = performance.now();
var DURATION = 2200;
function frame(now) {
var p = Math.min(1, (now - start) / DURATION);
// ease-out so it slows near the end like a real transfer
setProgress(1 - Math.pow(1 - p, 2));
if (p < 1) raf = requestAnimationFrame(frame);
else finish();
}
raf = requestAnimationFrame(frame);
}
function finish() {
btn.classList.remove('loading');
btn.classList.add('done');
setTimeout(function () {
btn.classList.remove('done');
setProgress(0);
}, 1900);
}
</script>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Download Button</title>
<!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
* {
box-sizing:border-box;margin:0;padding:0
}
body {
font-family:system-ui,-apple-system,sans-serif;background:#0f172a;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:24px
}
.dl-btn.loading {
background:#4338ca;cursor:default
}
.dl-btn.done {
background:#10b981;cursor:default
}
.dl-btn .dl-idle {
opacity:1;transform:translateY(0)
}
.dl-btn.loading .dl-idle {
opacity:0;transform:translateY(-8px)
}
.dl-btn.loading .dl-loading {
opacity:1;transform:translateY(0)
}
.dl-btn.done .dl-idle {
opacity:0
}
.dl-btn.done .dl-done {
opacity:1;transform:translateY(0)
}
.dl-pct,#dlPct {
font-variant-numeric:tabular-nums;font-size:14px;font-weight:700;min-width:34px;text-align:left
}
</style>
</head>
<body>
<div class="text-center">
<button class="dl-btn relative min-w-[180px] h-[52px] border-0 rounded-[14px] bg-[#6366f1] text-[#fff] font-[inherit] text-[15px] font-bold cursor-pointer overflow-hidden [transition:background_.3s] hover:bg-[#4f46e5] active:[transform:scale(.98)]" id="dlBtn" onclick="startDownload()">
<span class="absolute inset-0 flex items-center justify-center gap-2 opacity-0 [transform:translateY(8px)] [transition:opacity_.3s,transform_.3s] dl-idle">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
Download
</span>
<span class="absolute inset-0 flex items-center justify-center gap-2 opacity-0 [transform:translateY(8px)] [transition:opacity_.3s,transform_.3s] dl-loading">
<svg class="[transform:rotate(-90deg)]" viewBox="0 0 24 24" width="20" height="20">
<circle class="fill-none stroke-[rgba(255,255,255,.25)] [stroke-width:2.5]" cx="12" cy="12" r="9"/>
<circle class="fill-none stroke-[#fff] [stroke-width:2.5] [stroke-linecap:round] [stroke-dasharray:56.5] [stroke-dashoffset:56.5]" id="dlProg" cx="12" cy="12" r="9"/>
</svg>
<span id="dlPct">0%</span>
</span>
<span class="absolute inset-0 flex items-center justify-center gap-2 opacity-0 [transform:translateY(8px)] [transition:opacity_.3s,transform_.3s] dl-done">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
Saved
</span>
</button>
<p class="text-xs text-[#64748b]">Click to download · progress ring fills, then confirms</p>
</div>
<script>
var C = 2 * Math.PI * 9;
var btn = document.getElementById('dlBtn');
var prog = document.getElementById('dlProg');
var pct = document.getElementById('dlPct');
var raf = null;
function setProgress(p) {
prog.style.strokeDashoffset = (C * (1 - p)).toFixed(2);
pct.textContent = Math.round(p * 100) + '%';
}
function startDownload() {
if (btn.classList.contains('loading') || btn.classList.contains('done')) return;
btn.classList.add('loading');
setProgress(0);
var start = performance.now();
var DURATION = 2200;
function frame(now) {
var p = Math.min(1, (now - start) / DURATION);
// ease-out so it slows near the end like a real transfer
setProgress(1 - Math.pow(1 - p, 2));
if (p < 1) raf = requestAnimationFrame(frame);
else finish();
}
raf = requestAnimationFrame(frame);
}
function finish() {
btn.classList.remove('loading');
btn.classList.add('done');
setTimeout(function () {
btn.classList.remove('done');
setProgress(0);
}, 1900);
}
</script>
</body>
</html>import React, { useEffect } from 'react';
// CSS — optionally move to DownloadButton.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:24px}
.dl-stage{text-align:center}
.dl-btn{position:relative;min-width:180px;height:52px;border:none;border-radius:14px;background:#6366f1;color:#fff;font-family:inherit;font-size:15px;font-weight:700;cursor:pointer;overflow:hidden;transition:background .3s}
.dl-btn:hover{background:#4f46e5}
.dl-btn:active{transform:scale(.98)}
.dl-btn.loading{background:#4338ca;cursor:default}
.dl-btn.done{background:#10b981;cursor:default}
.dl-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:8px;opacity:0;transform:translateY(8px);transition:opacity .3s,transform .3s}
.dl-btn .dl-idle{opacity:1;transform:translateY(0)}
.dl-btn.loading .dl-idle{opacity:0;transform:translateY(-8px)}
.dl-btn.loading .dl-loading{opacity:1;transform:translateY(0)}
.dl-btn.done .dl-idle{opacity:0}
.dl-btn.done .dl-done{opacity:1;transform:translateY(0)}
.dl-ring{transform:rotate(-90deg)}
.dl-track{fill:none;stroke:rgba(255,255,255,.25);stroke-width:2.5}
.dl-prog{fill:none;stroke:#fff;stroke-width:2.5;stroke-linecap:round;stroke-dasharray:56.5;stroke-dashoffset:56.5}
.dl-pct,#dlPct{font-variant-numeric:tabular-nums;font-size:14px;font-weight:700;min-width:34px;text-align:left}
.dl-hint{font-size:12px;color:#64748b}
`;
export default function DownloadButton() {
// Auto-generated escape hatch: the original snippet's vanilla JS runs once
// after mount and queries the rendered DOM. For idiomatic React, lift this
// into state + handlers.
useEffect(() => {
const _listeners = [];
const _originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
_listeners.push({ target: this, type, listener, options });
return _originalAddEventListener.call(this, type, listener, options);
};
var C = 2 * Math.PI * 9;
var btn = document.getElementById('dlBtn');
var prog = document.getElementById('dlProg');
var pct = document.getElementById('dlPct');
var raf = null;
function setProgress(p) {
prog.style.strokeDashoffset = (C * (1 - p)).toFixed(2);
pct.textContent = Math.round(p * 100) + '%';
}
function startDownload() {
if (btn.classList.contains('loading') || btn.classList.contains('done')) return;
btn.classList.add('loading');
setProgress(0);
var start = performance.now();
var DURATION = 2200;
function frame(now) {
var p = Math.min(1, (now - start) / DURATION);
// ease-out so it slows near the end like a real transfer
setProgress(1 - Math.pow(1 - p, 2));
if (p < 1) raf = requestAnimationFrame(frame);
else finish();
}
raf = requestAnimationFrame(frame);
}
function finish() {
btn.classList.remove('loading');
btn.classList.add('done');
setTimeout(function () {
btn.classList.remove('done');
setProgress(0);
}, 1900);
}
// Cleanup: restore addEventListener and remove all listeners
return () => {
EventTarget.prototype.addEventListener = _originalAddEventListener;
for (const { target, type, listener, options } of _listeners) {
target.removeEventListener(type, listener, options);
}
};
// Expose handlers used by inline JSX events to the global scope
if (typeof startDownload === 'function') window.startDownload = startDownload;
}, []);
return (
<>
<style>{css}</style>
<div className="dl-stage">
<button className="dl-btn" id="dlBtn" onClick={(event) => { startDownload() }}>
<span className="dl-state dl-idle">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
Download
</span>
<span className="dl-state dl-loading">
<svg className="dl-ring" viewBox="0 0 24 24" width="20" height="20">
<circle className="dl-track" cx="12" cy="12" r="9"/>
<circle className="dl-prog" id="dlProg" cx="12" cy="12" r="9"/>
</svg>
<span id="dlPct">0%</span>
</span>
<span className="dl-state dl-done">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
Saved
</span>
</button>
<p className="dl-hint">Click to download · progress ring fills, then confirms</p>
</div>
</>
);
}import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+
export default function DownloadButton() {
// Auto-generated escape hatch: the original snippet's vanilla JS runs once
// after mount and queries the rendered DOM. For idiomatic React, lift this
// into state + handlers.
useEffect(() => {
const _listeners = [];
const _originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
_listeners.push({ target: this, type, listener, options });
return _originalAddEventListener.call(this, type, listener, options);
};
var C = 2 * Math.PI * 9;
var btn = document.getElementById('dlBtn');
var prog = document.getElementById('dlProg');
var pct = document.getElementById('dlPct');
var raf = null;
function setProgress(p) {
prog.style.strokeDashoffset = (C * (1 - p)).toFixed(2);
pct.textContent = Math.round(p * 100) + '%';
}
function startDownload() {
if (btn.classList.contains('loading') || btn.classList.contains('done')) return;
btn.classList.add('loading');
setProgress(0);
var start = performance.now();
var DURATION = 2200;
function frame(now) {
var p = Math.min(1, (now - start) / DURATION);
// ease-out so it slows near the end like a real transfer
setProgress(1 - Math.pow(1 - p, 2));
if (p < 1) raf = requestAnimationFrame(frame);
else finish();
}
raf = requestAnimationFrame(frame);
}
function finish() {
btn.classList.remove('loading');
btn.classList.add('done');
setTimeout(function () {
btn.classList.remove('done');
setProgress(0);
}, 1900);
}
// Cleanup: restore addEventListener and remove all listeners
return () => {
EventTarget.prototype.addEventListener = _originalAddEventListener;
for (const { target, type, listener, options } of _listeners) {
target.removeEventListener(type, listener, options);
}
};
// Expose handlers used by inline JSX events to the global scope
if (typeof startDownload === 'function') window.startDownload = startDownload;
}, []);
return (
<>
<style>{`
* {
box-sizing:border-box;margin:0;padding:0
}
body {
font-family:system-ui,-apple-system,sans-serif;background:#0f172a;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:24px
}
.dl-btn.loading {
background:#4338ca;cursor:default
}
.dl-btn.done {
background:#10b981;cursor:default
}
.dl-btn .dl-idle {
opacity:1;transform:translateY(0)
}
.dl-btn.loading .dl-idle {
opacity:0;transform:translateY(-8px)
}
.dl-btn.loading .dl-loading {
opacity:1;transform:translateY(0)
}
.dl-btn.done .dl-idle {
opacity:0
}
.dl-btn.done .dl-done {
opacity:1;transform:translateY(0)
}
.dl-pct,#dlPct {
font-variant-numeric:tabular-nums;font-size:14px;font-weight:700;min-width:34px;text-align:left
}
`}</style>
<div className="text-center">
<button className="dl-btn relative min-w-[180px] h-[52px] border-0 rounded-[14px] bg-[#6366f1] text-[#fff] font-[inherit] text-[15px] font-bold cursor-pointer overflow-hidden [transition:background_.3s] hover:bg-[#4f46e5] active:[transform:scale(.98)]" id="dlBtn" onClick={(event) => { startDownload() }}>
<span className="absolute inset-0 flex items-center justify-center gap-2 opacity-0 [transform:translateY(8px)] [transition:opacity_.3s,transform_.3s] dl-idle">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
Download
</span>
<span className="absolute inset-0 flex items-center justify-center gap-2 opacity-0 [transform:translateY(8px)] [transition:opacity_.3s,transform_.3s] dl-loading">
<svg className="[transform:rotate(-90deg)]" viewBox="0 0 24 24" width="20" height="20">
<circle className="fill-none stroke-[rgba(255,255,255,.25)] [stroke-width:2.5]" cx="12" cy="12" r="9"/>
<circle className="fill-none stroke-[#fff] [stroke-width:2.5] [stroke-linecap:round] [stroke-dasharray:56.5] [stroke-dashoffset:56.5]" id="dlProg" cx="12" cy="12" r="9"/>
</svg>
<span id="dlPct">0%</span>
</span>
<span className="absolute inset-0 flex items-center justify-center gap-2 opacity-0 [transform:translateY(8px)] [transition:opacity_.3s,transform_.3s] dl-done">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
Saved
</span>
</button>
<p className="text-xs text-[#64748b]">Click to download · progress ring fills, then confirms</p>
</div>
</>
);
}<template>
<div class="dl-stage">
<button class="dl-btn" id="dlBtn" @click="startDownload()">
<span class="dl-state dl-idle">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
Download
</span>
<span class="dl-state dl-loading">
<svg class="dl-ring" viewBox="0 0 24 24" width="20" height="20">
<circle class="dl-track" cx="12" cy="12" r="9"/>
<circle class="dl-prog" id="dlProg" cx="12" cy="12" r="9"/>
</svg>
<span id="dlPct">0%</span>
</span>
<span class="dl-state dl-done">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
Saved
</span>
</button>
<p class="dl-hint">Click to download · progress ring fills, then confirms</p>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
var C = 2 * Math.PI * 9;
let btn;
let prog;
let pct;
var raf = null;
function setProgress(p) {
prog.style.strokeDashoffset = (C * (1 - p)).toFixed(2);
pct.textContent = Math.round(p * 100) + '%';
}
function startDownload() {
if (btn.classList.contains('loading') || btn.classList.contains('done')) return;
btn.classList.add('loading');
setProgress(0);
var start = performance.now();
var DURATION = 2200;
function frame(now) {
var p = Math.min(1, (now - start) / DURATION);
// ease-out so it slows near the end like a real transfer
setProgress(1 - Math.pow(1 - p, 2));
if (p < 1) raf = requestAnimationFrame(frame);
else finish();
}
raf = requestAnimationFrame(frame);
}
function finish() {
btn.classList.remove('loading');
btn.classList.add('done');
setTimeout(function () {
btn.classList.remove('done');
setProgress(0);
}, 1900);
}
onMounted(() => {
btn = document.getElementById('dlBtn');
prog = document.getElementById('dlProg');
pct = document.getElementById('dlPct');
});
</script>
<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:24px}
.dl-stage{text-align:center}
.dl-btn{position:relative;min-width:180px;height:52px;border:none;border-radius:14px;background:#6366f1;color:#fff;font-family:inherit;font-size:15px;font-weight:700;cursor:pointer;overflow:hidden;transition:background .3s}
.dl-btn:hover{background:#4f46e5}
.dl-btn:active{transform:scale(.98)}
.dl-btn.loading{background:#4338ca;cursor:default}
.dl-btn.done{background:#10b981;cursor:default}
.dl-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:8px;opacity:0;transform:translateY(8px);transition:opacity .3s,transform .3s}
.dl-btn .dl-idle{opacity:1;transform:translateY(0)}
.dl-btn.loading .dl-idle{opacity:0;transform:translateY(-8px)}
.dl-btn.loading .dl-loading{opacity:1;transform:translateY(0)}
.dl-btn.done .dl-idle{opacity:0}
.dl-btn.done .dl-done{opacity:1;transform:translateY(0)}
.dl-ring{transform:rotate(-90deg)}
.dl-track{fill:none;stroke:rgba(255,255,255,.25);stroke-width:2.5}
.dl-prog{fill:none;stroke:#fff;stroke-width:2.5;stroke-linecap:round;stroke-dasharray:56.5;stroke-dashoffset:56.5}
.dl-pct,#dlPct{font-variant-numeric:tabular-nums;font-size:14px;font-weight:700;min-width:34px;text-align:left}
.dl-hint{font-size:12px;color:#64748b}
</style>// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-download-button',
standalone: true,
imports: [CommonModule],
encapsulation: ViewEncapsulation.None,
template: `
<div class="dl-stage">
<button class="dl-btn" id="dlBtn" (click)="startDownload()">
<span class="dl-state dl-idle">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
Download
</span>
<span class="dl-state dl-loading">
<svg class="dl-ring" viewBox="0 0 24 24" width="20" height="20">
<circle class="dl-track" cx="12" cy="12" r="9"/>
<circle class="dl-prog" id="dlProg" cx="12" cy="12" r="9"/>
</svg>
<span id="dlPct">0%</span>
</span>
<span class="dl-state dl-done">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
Saved
</span>
</button>
<p class="dl-hint">Click to download · progress ring fills, then confirms</p>
</div>
`,
styles: [`
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:24px}
.dl-stage{text-align:center}
.dl-btn{position:relative;min-width:180px;height:52px;border:none;border-radius:14px;background:#6366f1;color:#fff;font-family:inherit;font-size:15px;font-weight:700;cursor:pointer;overflow:hidden;transition:background .3s}
.dl-btn:hover{background:#4f46e5}
.dl-btn:active{transform:scale(.98)}
.dl-btn.loading{background:#4338ca;cursor:default}
.dl-btn.done{background:#10b981;cursor:default}
.dl-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:8px;opacity:0;transform:translateY(8px);transition:opacity .3s,transform .3s}
.dl-btn .dl-idle{opacity:1;transform:translateY(0)}
.dl-btn.loading .dl-idle{opacity:0;transform:translateY(-8px)}
.dl-btn.loading .dl-loading{opacity:1;transform:translateY(0)}
.dl-btn.done .dl-idle{opacity:0}
.dl-btn.done .dl-done{opacity:1;transform:translateY(0)}
.dl-ring{transform:rotate(-90deg)}
.dl-track{fill:none;stroke:rgba(255,255,255,.25);stroke-width:2.5}
.dl-prog{fill:none;stroke:#fff;stroke-width:2.5;stroke-linecap:round;stroke-dasharray:56.5;stroke-dashoffset:56.5}
.dl-pct,#dlPct{font-variant-numeric:tabular-nums;font-size:14px;font-weight:700;min-width:34px;text-align:left}
.dl-hint{font-size:12px;color:#64748b}
`]
})
export class DownloadButtonComponent implements AfterViewInit {
ngAfterViewInit(): void {
var C = 2 * Math.PI * 9;
var btn = document.getElementById('dlBtn');
var prog = document.getElementById('dlProg');
var pct = document.getElementById('dlPct');
var raf = null;
function setProgress(p) {
prog.style.strokeDashoffset = (C * (1 - p)).toFixed(2);
pct.textContent = Math.round(p * 100) + '%';
}
function startDownload() {
if (btn.classList.contains('loading') || btn.classList.contains('done')) return;
btn.classList.add('loading');
setProgress(0);
var start = performance.now();
var DURATION = 2200;
function frame(now) {
var p = Math.min(1, (now - start) / DURATION);
// ease-out so it slows near the end like a real transfer
setProgress(1 - Math.pow(1 - p, 2));
if (p < 1) raf = requestAnimationFrame(frame);
else finish();
}
raf = requestAnimationFrame(frame);
}
function finish() {
btn.classList.remove('loading');
btn.classList.add('done');
setTimeout(function () {
btn.classList.remove('done');
setProgress(0);
}, 1900);
}
// Bind inline-handler functions to the component so the template can call them
Object.assign(this, { setProgress, startDownload, finish });
}
}