SVG Progress Ring — Free HTML CSS JS Snippet
SVG Progress Ring · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="scene">
<div class="rings">
<div class="ring-wrap">
<svg width="100" height="100" viewBox="0 0 100 100">
<circle class="track" cx="50" cy="50" r="40"/>
<circle class="fill" cx="50" cy="50" r="40" id="r1" data-pct="75"/>
</svg>
<div class="ring-label"><span class="pct" id="p1">0</span>%<span class="desc">Performance</span></div>
</div>
<div class="ring-wrap">
<svg width="100" height="100" viewBox="0 0 100 100">
<circle class="track" cx="50" cy="50" r="40"/>
<circle class="fill pink" cx="50" cy="50" r="40" id="r2" data-pct="92"/>
</svg>
<div class="ring-label"><span class="pct" id="p2">0</span>%<span class="desc">Accessibility</span></div>
</div>
<div class="ring-wrap">
<svg width="100" height="100" viewBox="0 0 100 100">
<circle class="track" cx="50" cy="50" r="40"/>
<circle class="fill green" cx="50" cy="50" r="40" id="r3" data-pct="58"/>
</svg>
<div class="ring-label"><span class="pct" id="p3">0</span>%<span class="desc">SEO</span></div>
</div>
</div>
<button class="replay" onclick="animateRings()">Replay ↺</button>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f172a; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.scene { display: flex; flex-direction: column; align-items: center; gap: 28px; }
.rings { display: flex; gap: 24px; flex-wrap: wrap; justify-content: center; }
.ring-wrap { display: flex; flex-direction: column; align-items: center; gap: 10px; }
svg { transform: rotate(-90deg); }
.track { fill: none; stroke: #1e293b; stroke-width: 8; }
.fill {
fill: none; stroke: #6366f1; stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 251.2;
stroke-dashoffset: 251.2;
transition: stroke-dashoffset 1.2s cubic-bezier(0.4,0,0.2,1);
}
.fill.pink { stroke: #ec4899; }
.fill.green { stroke: #10b981; }
.ring-label { text-align: center; transform: none; }
.pct { font-size: 20px; font-weight: 800; color: #f1f5f9; display: block; line-height: 1; }
.desc { font-size: 11px; color: #475569; display: block; margin-top: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.replay { padding: 8px 20px; background: none; border: 1px solid #334155; border-radius: 8px; font-size: 12px; font-weight: 600; color: #475569; cursor: pointer; font-family: inherit; transition: all 0.15s; }
.replay:hover { border-color: #6366f1; color: #6366f1; }const CIRCUMFERENCE = 2 * Math.PI * 40; // 251.2
function animateRings() {
document.querySelectorAll('.fill').forEach((circle, i) => {
const pct = parseFloat(circle.dataset.pct);
const pctEl = document.getElementById('p' + (i+1));
circle.style.strokeDashoffset = CIRCUMFERENCE;
pctEl.textContent = '0';
setTimeout(() => {
circle.style.strokeDashoffset = CIRCUMFERENCE * (1 - pct / 100);
let cur = 0;
const step = pct / 60;
const t = setInterval(() => {
cur = Math.min(pct, cur + step);
pctEl.textContent = Math.floor(cur);
if (cur >= pct) clearInterval(t);
}, 20);
}, i * 150);
});
}
animateRings();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
SVG Progress Ring — stroke-dashoffset, CIRCUMFERENCE Formula & Count-Up

An SVG progress ring shows a percentage or progress value as a circular arc — used in analytics dashboards, skill showcases, goal-completion displays, and loading screens. The ring is built entirely with an SVG circle element and two CSS properties: stroke-dasharray and stroke-dashoffset. No canvas, no library, no images.
The mathematics: CIRCUMFERENCE = 2 × Math.PI × radius. Setting stroke-dasharray: CIRCUMFERENCE makes the stroke one continuous dash equal to the full circle perimeter. stroke-dashoffset controls where that dash starts along the perimeter — setting it to CIRCUMFERENCE hides the full ring (empty); setting it to 0 shows the full ring (100%). The fill formula: dashoffset = CIRCUMFERENCE × (1 - percentage / 100).
The rotate(-90deg) on the circle element moves the stroke start from 3 o'clock (SVG default) to 12 o'clock — the orientation users expect for progress rings. This is applied via CSS transform on the circle element.
The snippet shows three rings simultaneously at different radii, colours, and target percentages. Each ring has its own CIRCUMFERENCE calculation and a separate requestAnimationFrame count-up animation that ease-outs as it approaches the target value. The count-up uses a quartic ease-out function: progress = 1 - Math.pow(1 - t, 4) where t is elapsed time divided by duration.
The CSS transition: stroke-dashoffset 1s ease on the circle element provides an alternative to the JS animation — changing the data-pct attribute and computing dashoffset triggers the CSS transition automatically without any requestAnimationFrame loop. Use CSS transition for static displays; use the JS animation for dynamic updates that need synchronised number count-up.
Multiple rings at different sizes: change the r attribute and viewBox dimensions together. Keep the stroke-width proportional to the radius for visual consistency — a 52px radius ring looks best with a 6-8px stroke; a 100px radius ring can use 10-12px. Larger strokes make the ring appear as a thick gauge; thinner strokes give a delicate circular progress appearance.
For accessibility, add an aria-valuenow attribute that updates with the animated percentage, and aria-valuemin="0" and aria-valuemax="100" to communicate the range to screen readers. This ensures the progress value is announced correctly by assistive technology as the ring fills.
Step by step
How to Use
- 1Watch the rings animateThe three rings count up from 0 to their target percentages using requestAnimationFrame with ease-out deceleration.
- 2Update ring percentagesIn the HTML panel, change the data-pct attribute on each .fill circle element to your target percentage (0-100).
- 3Change ring coloursUpdate stroke colour on .fill elements in the CSS panel — or use different stroke colours per ring via inline stroke attributes.
- 4Change the ring sizeUpdate r="40" on the SVG circles and recalculate CIRCUMFERENCE = 2 * Math.PI * newR in the JS panel.
- 5Add labelsUpdate the percentage text and ring label in the HTML panel for each .ring-wrap div.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
CIRCUMFERENCE = 2*Math.PI*radius = 2*3.14159*40 = 251.2px. stroke-dasharray: 251.2 sets the full ring as one dash exactly one circumference long. stroke-dashoffset shifts the start of that dash: at 0 the full ring is visible; at 251.2 it is fully hidden. To show 75%: offset = 251.2 * (1 - 0.75) = 62.8px.
SVG starts drawing at the 3 o'clock position (rightmost point of the circle). Rotating by -90 degrees moves the starting point to 12 o'clock (top centre), which is the conventional start for progress indicators — matching how clock hands, speedometers, and activity rings work.
Update r="40" on both the .track and .fill circle elements. Update cx and cy to half your new SVG viewBox size. In the JS panel, update const CIRCUMFERENCE = 2 * Math.PI * newRadius. The stroke-width (8) can also be adjusted proportionally to the new size.
Wrap animateRings() in an IntersectionObserver: const obs = new IntersectionObserver(entries => { entries.forEach(e => { if(e.isIntersecting) { animateRings(); obs.disconnect(); } }); }, { threshold: 0.5 }); obs.observe(document.querySelector(".rings")). This fires exactly once when the rings section scrolls into view.
Add a stroke attribute directly on each .fill circle: <circle class="fill" stroke="#22c55e" ...>. The inline attribute overrides the CSS stroke colour for that specific element. Use semantic colours: green for good performance, amber for moderate, red for below target.
Yes. Create a ProgressRing component with pct and colour props. Use useEffect to start the animation on mount: the effect reads the pct prop and animates strokeDashoffset via useRef on the circle element. Pass an animate={true} prop to trigger the animation when the component becomes visible.