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>

Async Task Completion Ring — Real Progress SVG Circle in JS

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

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Instead of assuming the ring is just a timed CSS animation, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how stroke-dasharray and stroke-dashoffset combine to reveal a precise fraction of the circle, and how the completed counter inside each subtask's .then() callback keeps that fraction mathematically tied to real Promise settlement rather than elapsed time. The same assistant can help optimize it — for instance asking whether tracking progress via a single shared mutable counter could race under truly concurrent updates, and whether an atomic increment pattern or a reduce over settled results would be safer. It's also useful for extending it: ask it to add per-task error states using Promise.allSettled instead of Promise.all, show individual task timing, or expose the whole thing as a reusable function that takes an arbitrary array of promises. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a circular "async task completion" progress ring in plain HTML, CSS, and JavaScript — no libraries — where the percentage shown is driven by REAL accumulating completion of concurrently-running async work, not a fixed-duration CSS animation or a fake incrementing timer.

Requirements:
- An SVG ring made of two overlapping circles (a static track and a colored fill) using the stroke-dasharray / stroke-dashoffset technique, where stroke-dasharray equals the circle's circumference and stroke-dashoffset is set from JavaScript to circumference times (1 minus the completed fraction).
- A center label showing both a percentage and a "completed / total" fraction, both computed from a real running count of resolved promises.
- Simulate several (five or six) independent async subtasks, each returning a Promise that resolves after its own randomized delay (standing in for real network calls of varying duration) — not all resolving at the same fixed time.
- As each subtask's promise resolves, increment a shared completed counter and recompute the ring's fill and the center label from that counter divided by the total — the displayed percentage at any moment must reflect genuinely settled promises, never an assumed elapsed-time percentage.
- Render a list below the ring with one row per subtask, each showing its own spinning icon that changes to a checkmark exactly when that specific subtask's promise resolves.
- Use Promise.all over all the subtask promises to detect true full completion (turning the ring a completion color and re-enabling a "run again" button), not a fixed delay guessed to be long enough.
- Include a button to re-run the whole simulation with fresh random delays.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

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 onboarding / setup sequences
Show real progress while multiple setup calls (auth, profile, preferences) run concurrently.
Batch API or file operations
Visualize true completion of parallelized requests, pairing well with a multi-file upload queue.
Multi-step form submission
Track several async validation or save calls that fire together on submit.
Teaching Promise.all progress patterns
A clear reference for driving UI state from genuine promise settlement instead of a timer.
Dashboard data-warming screens
Show a ring while several data sources load in parallel before a dashboard renders.
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.