Concentric Rings Progress Loader — Multi-Stage SVG Loading Indicator
Concentric Rings Progress Loader · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="demo">
<div class="rings-wrap">
<svg class="rings-svg" viewBox="0 0 120 120" role="img" aria-label="Loading three stages: assets, config, data">
<circle class="ring-track" cx="60" cy="60" r="52" />
<circle class="ring-track" cx="60" cy="60" r="38" />
<circle class="ring-track" cx="60" cy="60" r="24" />
<circle class="ring-fill ring-1" cx="60" cy="60" r="52" />
<circle class="ring-fill ring-2" cx="60" cy="60" r="38" />
<circle class="ring-fill ring-3" cx="60" cy="60" r="24" />
</svg>
<div class="rings-center">
<span class="rings-pct" id="pct">0%</span>
<span class="rings-label" id="label">Loading assets…</span>
</div>
</div>
<div class="rings-legend">
<span><i style="background:#6366f1"></i>Assets</span>
<span><i style="background:#22d3ee"></i>Config</span>
<span><i style="background:#34d399"></i>Data</span>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.demo { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.rings-wrap { position: relative; width: 160px; height: 160px; }
.rings-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-track { fill: none; stroke: #eef2ff; stroke-width: 7; }
.ring-fill { fill: none; stroke-width: 7; stroke-linecap: round; transition: stroke-dashoffset 0.4s cubic-bezier(.4,0,.2,1); }
.ring-1 { stroke: #6366f1; stroke-dasharray: 326.7; stroke-dashoffset: 326.7; }
.ring-2 { stroke: #22d3ee; stroke-dasharray: 238.8; stroke-dashoffset: 238.8; }
.ring-3 { stroke: #34d399; stroke-dasharray: 150.8; stroke-dashoffset: 150.8; }
.rings-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; }
.rings-pct { font-size: 22px; font-weight: 800; color: #111827; font-variant-numeric: tabular-nums; }
.rings-label { font-size: 10.5px; font-weight: 600; color: #94a3b8; text-align: center; padding: 0 10px; }
.rings-legend { display: flex; gap: 16px; }
.rings-legend span { display: flex; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 600; color: #64748b; }
.rings-legend i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }const ring1 = document.querySelector('.ring-1');
const ring2 = document.querySelector('.ring-2');
const ring3 = document.querySelector('.ring-3');
const pctEl = document.getElementById('pct');
const labelEl = document.getElementById('label');
const stages = [
{ ring: ring1, dasharray: 326.7, label: 'Loading assets…' },
{ ring: ring2, dasharray: 238.8, label: 'Applying config…' },
{ ring: ring3, dasharray: 150.8, label: 'Fetching data…' },
];
function setRing(ring, dasharray, progress) {
ring.style.strokeDashoffset = dasharray * (1 - progress);
}
let overall = 0;
let stageIndex = 0;
let stageProgress = 0;
function tick() {
if (stageIndex >= stages.length) return;
stageProgress += 0.045;
const current = stages[stageIndex];
setRing(current.ring, current.dasharray, Math.min(stageProgress, 1));
overall = ((stageIndex + Math.min(stageProgress, 1)) / stages.length) * 100;
pctEl.textContent = Math.round(overall) + '%';
labelEl.textContent = current.label;
if (stageProgress >= 1) {
stageIndex += 1;
stageProgress = 0;
if (stageIndex < stages.length) {
labelEl.textContent = stages[stageIndex].label;
} else {
labelEl.textContent = 'Ready';
pctEl.textContent = '100%';
return;
}
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);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.
What's included
Features
About this UI Snippet
Concentric Rings Progress Loader — One Ring per Loading Stage

A single spinner or progress bar can only communicate "something is happening" and, at best, one overall percentage. This loader instead uses three nested SVG rings, filled one at a time in sequence, so a genuinely multi-stage loading process — asset loading, then config, then data — has a visual home for each individual stage's progress, not just a single blended number.
The stroke-dasharray / stroke-dashoffset technique
Each ring is an SVG <circle> with no fill, just a stroke, and a stroke-dasharray set to that circle's exact circumference (2 × π × r, precomputed per radius — 326.7 for r=52, 238.8 for r=38, 150.8 for r=24). Because the dash pattern is exactly one segment as long as the whole circle, the circle appears either fully drawn or, when stroke-dashoffset equals the same value, entirely hidden. Animating stroke-dashoffset from that full value down to 0 makes the ring appear to fill in continuously, clockwise from its start point — a standard SVG progress-ring trick, applied here three times independently.
Why the SVG is rotated -90 degrees
SVG circles start their path at the 3 o'clock position by default. Rotating the whole <svg> element -90deg moves that starting point to 12 o'clock, which is the conventional "start" position for a progress indicator — the rotation is applied once to the parent SVG rather than adjusting each circle's path math individually.
Sequencing three independent progress values into one loop
A single requestAnimationFrame loop (tick) advances one shared stageProgress value for whichever ring is currently active (stages[stageIndex]). Once that stage reaches 1 (fully filled), the loop increments stageIndex, resets stageProgress to 0, and continues animating the *next* ring — so only one ring is ever actively filling at a time, while previously completed rings simply stay full. The center percentage (overall) is derived from both values together: ((stageIndex + stageProgress) / stages.length) * 100, so it advances smoothly across the whole sequence rather than jumping in three big steps.
Where a multi-stage loader beats a single progress bar
Multi-step processes — app cold-start sequences, multi-phase file uploads, onboarding data imports — genuinely have distinct phases with their own completion criteria. Showing them as one blended percentage hides *which* phase is currently slow; three separate, individually-labeled rings that fill one after another give a user (or a developer debugging a slow load) a much clearer read on exactly what's happening and roughly how far along it is.
Step by step
How to Use
- 1Adjust the ring radii and dasharray values togetherIf you change an r attribute on a circle, recompute its stroke-dasharray as 2 × π × r and update both the SVG and matching JS dasharray values.
- 2Edit the stages arrayEach stage needs a ring element reference, its matching dasharray number, and a label string shown while it is active.
- 3Tune the fill speedChange the 0.045 increment added to stageProgress each frame — a smaller number fills more slowly, a larger one more quickly.
- 4Replace the simulated progress with real progressInstead of incrementing stageProgress by a fixed amount per frame, set it directly from real load-event progress (e.g. a fetch's reported bytes loaded).
- 5Swap the ring colorsUpdate the stroke colors on .ring-1/.ring-2/.ring-3 and the matching legend dot colors in the HTML panel to fit your palette.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Setting stroke-dasharray to the circle's exact circumference creates one dash segment as long as the whole circle, so the ring renders as fully drawn. Animating stroke-dashoffset toward 0 progressively reveals more of that segment from the start point, visually filling the ring.
SVG circles begin their path at the 3 o'clock position by default. Rotating the whole SVG element -90 degrees moves the visual starting point to 12 o'clock, matching the conventional orientation for a circular progress indicator.
Yes — remove the stageIndex advancement logic and instead update all three rings' stroke-dashoffset independently based on three separate real progress values reported concurrently, rather than gating them one after another.
Replace the fixed 0.045-per-frame increment in tick() with an assignment driven by real progress events — for example a fetch response's reported bytes loaded divided by total bytes for that stage.
The tick() function detects stageIndex has advanced past the last stage, sets the label to "Ready" and the percentage to a fixed 100%, and returns without scheduling another animation frame, stopping the loop cleanly.
The SVG has role="img" with a descriptive aria-label summarizing all three stages up front. For live progress updates specifically, consider adding aria-live="polite" to the percentage or label element so changes are announced as loading proceeds.