Download Button — Progress Ring HTML CSS JS Snippet

Download Button · Buttons · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Three-state cross-fade
Idle, loading, and done layers stack and toggle by class, cross-fading with opacity/transform at a fixed button size — no width-morph jank.
JS-driven progress ring
stroke-dashoffset is updated every requestAnimationFrame rather than via CSS transition, so the fill is smooth and export-safe.
Stroke-dash arc
The ring uses stroke-dasharray = circumference and a shrinking offset to draw the arc — the standard SVG progress technique.
Top-start fill
The SVG is rotated -90deg so progress begins at 12 o'clock, the conventional direction users expect.
Eased progress curve
An ease-out (1 - (1 - p)²) makes the ring slow near the end, feeling like a real transfer instead of a robotic linear fill.
Synced percentage
The number and the arc are derived from the same p, so they can never drift out of agreement.
Re-entrancy guard
startDownload ignores clicks while loading or done, so rapid clicks cannot stack or restart the animation.
Auto-reset
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

Screenshot of the Download Button snippet rendered live

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

  1. 1
    Paste HTML, CSS, and JSA purple "Download" button with a download icon appears on a dark background.
  2. 2
    Click itThe label cross-fades to a progress ring that fills from the top while a percentage counts up beside it.
  3. 3
    Watch the easingThe ring races ahead early and eases as it nears 100%, mimicking a real transfer rather than a linear bar.
  4. 4
    See the success stateAt 100% the button turns green and shows a checkmark with "Saved".
  5. 5
    It auto-resetsAfter about two seconds it fades back to the idle "Download" state, ready to run again.
  6. 6
    Wire a real transferReplace the timer by calling setProgress from a fetch stream or XHR progress event with the real loaded/total ratio.

Real-world uses

Common Use Cases

File and report downloads
Show real progress while generating or fetching a file. Pair with an upload progress component for the reverse flow.
Export actions
"Export CSV/PDF" buttons where generation takes a moment — the ring reassures users it is working.
Save and sync buttons
Save settings or sync data with visible progress; a richer alternative to a plain loading button.
Install / update actions
App or plugin install buttons that show download progress then a success check.
Media and asset fetching
Downloading large images, videos, or design assets where a determinate ring beats an indeterminate spinner.
Checkout and purchase confirms
Processing states for orders; combine with an add to cart button for the full purchase flow.

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

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>
  <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>
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>
  <!-- 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>
React
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>
    </>
  );
}
React + Tailwind
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>
    </>
  );
}
Vue
<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>
Angular
// @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 });
  }
}