Circular Progress Ring — SVG HTML CSS JS Snippet
Circular Progress Ring · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="demo">
<div class="ring-row">
<div class="ring-wrap">
<svg class="ring" viewBox="0 0 120 120">
<circle class="ring-track" cx="60" cy="60" r="52"></circle>
<circle class="ring-fill" id="ringA" cx="60" cy="60" r="52"></circle>
</svg>
<div class="ring-label"><span id="valA">68</span>%</div>
</div>
<div class="ring-wrap sm">
<svg class="ring" viewBox="0 0 120 120">
<circle class="ring-track" cx="60" cy="60" r="52"></circle>
<circle class="ring-fill ring-warn" id="ringB" cx="60" cy="60" r="52"></circle>
</svg>
<div class="ring-label"><span id="valB">30</span>%</div>
</div>
<div class="ring-wrap sm">
<svg class="ring" viewBox="0 0 120 120">
<circle class="ring-track" cx="60" cy="60" r="52"></circle>
<circle class="ring-fill ring-danger" id="ringC" cx="60" cy="60" r="52"></circle>
</svg>
<div class="ring-label"><span id="valC">92</span>%</div>
</div>
</div>
<div class="controls">
<label>Storage used: <span id="sliderVal">68</span>%</label>
<input type="range" id="slider" min="0" max="100" value="68">
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8f9fa; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.demo { width: 100%; max-width: 420px; }
.ring-row { display: flex; align-items: center; justify-content: center; gap: 24px; margin-bottom: 28px; }
.ring-wrap { position: relative; width: 128px; height: 128px; }
.ring-wrap.sm { width: 88px; height: 88px; }
.ring { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-track { fill: none; stroke: #eef0f3; stroke-width: 10; }
.ring-fill { fill: none; stroke: #2563eb; stroke-width: 10; stroke-linecap: round; stroke-dasharray: 326.7; stroke-dashoffset: 326.7; transition: stroke-dashoffset 0.5s cubic-bezier(.4,0,.2,1); }
.ring-warn { stroke: #d97706; }
.ring-danger { stroke: #dc2626; }
.ring-label { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 700; color: #111827; }
.ring-wrap.sm .ring-label { font-size: 15px; }
.controls { text-align: center; }
.controls label { display: block; font-size: 13px; color: #4b5563; margin-bottom: 8px; font-weight: 600; }
.controls input { width: 100%; }var CIRCUMFERENCE = 2 * Math.PI * 52;
function setRing(el, valueEl, pct) {
pct = Math.max(0, Math.min(100, pct));
var offset = CIRCUMFERENCE - (pct / 100) * CIRCUMFERENCE;
el.style.strokeDashoffset = offset;
valueEl.textContent = Math.round(pct);
}
setRing(document.getElementById('ringA'), document.getElementById('valA'), 68);
setRing(document.getElementById('ringB'), document.getElementById('valB'), 30);
setRing(document.getElementById('ringC'), document.getElementById('valC'), 92);
var slider = document.getElementById('slider');
var sliderVal = document.getElementById('sliderVal');
var ringA = document.getElementById('ringA');
var valA = document.getElementById('valA');
slider.addEventListener('input', function() {
var pct = Number(slider.value);
sliderVal.textContent = pct;
setRing(ringA, valA, pct);
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), 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
Circular Progress Ring — SVG stroke-dashoffset Technique

A circular progress ring shows completion as an arc filling around a circle rather than a horizontal bar — used for storage quotas, upload progress, goal completion, and dashboard KPI tiles where a compact round shape fits better than a linear one. This snippet builds the ring from two stacked SVG circles and animates the fill purely by rewriting one stroke property.
Two stacked circles
Each ring is an <svg> containing a .ring-track circle (the full gray background ring) and a .ring-fill circle drawn directly on top of it with an accent color. Both share the same cx, cy, and r — only the fill circle's stroke properties change to represent progress.
stroke-dasharray and stroke-dashoffset
stroke-dasharray is set to the circle's full circumference (2 × π × r, or 326.7 for r=52), which turns the stroke into one dash exactly as long as the circle itself — visually indistinguishable from a solid stroke at rest. stroke-dashoffset then shifts where that dash starts drawing from. Setting the offset equal to the full circumference makes the dash start exactly one full lap away, so none of the stroke is visible; setting it to circumference × (1 − pct/100) reveals exactly pct% of the ring. This is the standard SVG technique for drawable progress rings and expanding donut charts alike, and it needs no <canvas>, no clip-path, and no JavaScript-driven redraw loop.
Why the SVG is rotated -90deg
An SVG circle's stroke naturally starts drawing from the 3 o'clock position and proceeds clockwise. transform: rotate(-90deg) on the whole <svg> rotates that starting point to 12 o'clock, which is the conventional "empty to full, starting at the top" reading direction users expect from a progress ring.
Animating the fill
The CSS transition: stroke-dashoffset 0.5s cubic-bezier(.4,0,.2,1) means any JavaScript update to the offset animates smoothly rather than jumping instantly — setRing() only ever sets the target strokeDashoffset value once per call; the browser's compositor handles interpolating between the old and new value.
Percentage math lives in one function
setRing(el, valueEl, pct) is the single place that converts a percentage into a dashoffset and updates the paired numeric label — every ring on the page, regardless of size or color, calls the same function, so the circumference constant (CIRCUMFERENCE = 2 * Math.PI * 52) only needs to match the r value used in the SVG markup, and changing the ring's radius means updating exactly one number.
Threshold-based coloring
The three demo rings use three color modifier classes — .ring-fill (blue, default), .ring-warn (amber), .ring-danger (red) — applied as a plain class swap. In a real dashboard, choose the class based on the value itself (for example, red above 90%, amber above 70%, blue otherwise) so the ring communicates urgency without the user needing to read the number.
Sizing variants
The .ring-wrap.sm modifier only changes the container's width/height and the label's font size — because the SVG uses a viewBox rather than fixed pixel dimensions, it scales cleanly to any container size without recalculating the stroke width or dash values, though very small rings may need a thinner stroke-width to avoid the ring looking disproportionately thick.
Live updates from real data
The slider in the demo stands in for any real value source — replace its input event with a fetch response handler, a WebSocket message, or a polling interval, and call setRing() with the new percentage each time new data arrives.
See also the gauge chart for a half-circle variant of the same technique, and the quota usage meter for a linear-bar alternative to this ring.
Step by step
How to Use
- 1Copy one ring-wrap blockEach ring is a .ring-wrap div containing an SVG with a .ring-track and .ring-fill circle, plus a .ring-label overlay for the numeric text.
- 2Match the circumference constant to your radiusIf you change the circle's r attribute from 52, recompute CIRCUMFERENCE as 2 * Math.PI * yourRadius and update stroke-dasharray in the CSS to match.
- 3Call setRing with a percentagesetRing(fillCircleElement, labelElement, percentage) updates both the ring's fill and its numeric label in one call — use it for both the initial render and any live updates.
- 4Pick a color based on the valueSwap the ring-warn or ring-danger class onto .ring-fill when a value crosses your thresholds, so color communicates urgency without extra text.
- 5Resize with .ring-wrap.sm or your own variantAdd a size modifier class changing only the wrapper's width/height and label font-size — the SVG viewBox handles the rest automatically.
Real-world uses
Common Use Cases
setRing() function for rings of any size.stroke-dashoffset transition.Got questions?
Frequently Asked Questions
Adjust stroke-width on both .ring-track and .ring-fill — keep them equal so the fill sits centered over the track. A thicker stroke may need a slightly larger radius to avoid the ring looking cramped inside its viewBox.
An SVG circle's stroke starts drawing at the 3 o'clock position by default. Rotating the whole SVG -90 degrees moves that starting point to 12 o'clock, which matches how progress rings are conventionally read — starting at the top and filling clockwise.
Listen to your upload mechanism's progress event (for example XMLHttpRequest's progress event with loaded/total, or a streamed fetch reader) and call setRing(fillEl, labelEl, (loaded/total)*100) on each update — the same function used for the demo slider.
Yes — remove the dashoffset percentage logic, set stroke-dasharray to roughly 25% of the circumference so only a partial arc is visible, and add a CSS animation that rotates the whole ring continuously, similar to a classic spinner but ring-shaped.
Open the Export menu on the snippet page for a React component computing dashoffset from a percentage prop, a React + Tailwind version, a Vue 3 SFC, or an Angular standalone component with an @Input() percentage — all preserve the SVG structure and the animated transition.