webdevpuneet.com

Download Button — Progress Ring HTML CSS JS Snippet

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

CategoryButtons

Copy & paste

Source Code

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

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

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.
Related: Fullscreen API Toggle Button
See the Fullscreen API Toggle Button for a related buttons pattern worth pairing with this one.

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.