webdevpuneet.com

Async Task Completion Ring — Real Progress SVG Circle in JS

Async Task Completion Ring · Loaders · Plain HTML, CSS & JS · Live preview

CategoryLoaders

Copy & paste

Source Code

<div class="tc-wrap">
  <div class="tc-ring-wrap">
    <svg class="tc-ring" viewBox="0 0 140 140">
      <circle class="tc-track" cx="70" cy="70" r="60"></circle>
      <circle class="tc-fill" id="tcFill" cx="70" cy="70" r="60"></circle>
    </svg>
    <div class="tc-center">
      <span class="tc-pct" id="tcPct">0%</span>
      <span class="tc-count" id="tcCount">0 / 6 tasks</span>
    </div>
  </div>
  <ul class="tc-list" id="tcList"></ul>
  <button class="tc-run" id="tcRun" type="button">Run tasks again</button>
</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

Ring percentage driven by real Promise.all-based task accumulation, never a guessed timer
SVG stroke-dasharray / stroke-dashoffset technique for the circular fill
Per-task list with independent spinner-to-checkmark icons synced to the same state
Randomized per-task delays simulate realistic, non-uniform async completion
Promise.all gates the finished state and button re-enable on true completion
Smooth CSS transition on stroke-dashoffset for each incremental fill step
Re-runnable demo with a visible button, useful for testing the animation repeatedly
Reusable setProgress(completed, total) function decoupled from the simulated delay
Zero dependencies — vanilla Promises and SVG
Live center percentage and fraction label

About this UI Snippet

Async Task Completion Ring — An SVG Progress Circle Driven by Real Promise Completion

Screenshot of the Async Task Completion Ring snippet rendered live

Most circular progress rings on the web animate to a hardcoded percentage over a fixed CSS duration — the ring has no idea whether the underlying work is actually 40% or 90% done. This snippet does something different: it kicks off six independently-timed async subtasks with Promise-based random delays, and the ring's fill, center percentage, and task-list checkmarks are all driven by counting how many of those promises have genuinely resolved so far.

Real accumulation, not a guessed duration

runSubtask() wraps a randomized setTimeout (between 500ms and roughly 2.3s) in a Promise, standing in for real asynchronous work like a network request. Each subtask's .then() increments a shared completed counter and calls setProgress(completed, TASKS.length) — so the ring only ever shows completed / total, a number that is true at every instant, not a percentage interpolated from an assumed total time. If a real task ran twice as long as expected, the ring would simply wait longer at whatever percentage was last true, rather than lying by finishing early.

The SVG stroke-dashoffset technique

The ring itself is two overlapping SVG <circle> elements: a static .tc-track and a .tc-fill with stroke-dasharray set to the circle's circumference (2 * Math.PI * r) and stroke-dashoffset set to the same value minus the completed fraction. Offsetting the dash pattern by exactly circumference * (1 - pct) reveals precisely that fraction of the ring, and a CSS transition on stroke-dashoffset smooths each jump into a short animated fill rather than a hard snap.

A task list that mirrors the ring

Below the ring, each task renders as a row with its own small spinner icon. As each subtask's promise resolves, that row's icon swaps from a spinning border to a checkmark built with a rotated CSS pseudo-element border — giving a second, itemized view of the exact same real completion state the ring is summarizing numerically.

Promise.all for the finishing state

Promise.all(promises) re-enables the "Run tasks again" button only once every subtask has genuinely settled, and the ring's stroke turns green via a .tc-done class at that same true completion moment — there's no separate "wait a bit longer, then pretend we're done" timer.

Customizing it

Replace runSubtask's setTimeout with real fetch() calls or other async work — the setProgress, checkmark, and Promise.all completion logic need no changes, since they only care about when each promise settles, not what it does. Adjust the TASKS array to match your app's real setup sequence, or reuse the setProgress(completed, total) function against any other async accumulation, like a batch upload or a multi-file import. Pair it with a multi-file upload queue for a related real-progress pattern.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSSix simulated subtasks start immediately and the ring begins filling as they complete.
  2. 2
    Watch the ring and list togetherThe center percentage, task count, and per-task checkmarks all update from the same real completion count.
  3. 3
    Reach 100%The ring turns green and the button re-enables once every subtask has actually settled.
  4. 4
    Click "Run tasks again"A fresh run starts with new randomized delays, so completion order and timing vary each time.
  5. 5
    Swap in real async workReplace the setTimeout inside runSubtask with a real fetch() call or other Promise-returning work.
  6. 6
    Reuse the progress functionCall setProgress(completed, total) from any other async accumulation you need to visualize.

Real-world uses

Common Use Cases

🚀
App setup sequences
Show real progress while several startup tasks run, with the ring percentage accumulating as each Promise.all task settles.
📦
Batch API and file operations
Visualise true completion of a batch, with a per-task list whose spinners turn into checkmarks in sync with the ring.
✅
Multi-step submission checks
Track several asynchronous validation checks before a form is sent, showing real completion instead of a guessed or purely decorative percentage.
📤
Queued upload comparison
See multi-file upload queues for the file-by-file version of the same real-progress idea, with one bar per file.
🎓
Promise.all progress teaching
Learn how randomised per-task delays produce non-uniform completion and how to count resolved tasks without waiting for the whole group.
Related: Async Button with Real Progress Fill
See the Async Button with Real Progress Fill for a related real-progress pattern worth pairing with this one.
Related: Realistic Network Progress Bar
See the Realistic Network Progress Bar for a related loaders pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

It is genuinely accurate. Each of the six subtasks is a Promise wrapping a randomized-delay setTimeout standing in for real async work. As each promise resolves, a shared completed counter increments and setProgress(completed, total) recomputes the true fraction — the ring never assumes a fixed total duration.

Two circles overlap: a static track and a fill circle with stroke-dasharray set to its circumference (2 * Math.PI * r) and stroke-dashoffset set to circumference * (1 - percentComplete). Offsetting the dash pattern by that amount visually reveals exactly that fraction of the ring\u2019s stroke.

Replace the body of runSubtask so it returns a real Promise from your own work (a fetch call, a database query, etc.) instead of the setTimeout. Everything downstream — the completed counter, setProgress, checkmarks, and Promise.all completion state — works unchanged because it only depends on when the promise settles.

Each subtask\u2019s delay is 500ms plus a random amount up to about 1.8 seconds, generated fresh on every runAll() call. This deliberately avoids uniform, robotic-looking completion and better mirrors how real concurrent network calls actually finish at different times.

Promise.all(promises) would never fulfill, so the ring would remain at its last true percentage and the "Run tasks again" button would stay disabled — which is the correct, honest behavior, unlike a fixed-duration animation that would incorrectly show 100% regardless of whether the real work finished.

Yes. Keep a completed count and a total in state, run your real async calls (or Promise.all against several) inside a mount effect, and increment state in each one\u2019s .then(). Bind stroke-dashoffset to a computed value derived from that state; the SVG structure and CSS transition carry over directly.