Donut Progress — Animated Percentage Ring with Label
Donut Progress · Charts · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="dp-card">
<div class="dp-ring" id="dpRing">
<svg viewBox="0 0 120 120">
<circle class="dp-track" cx="60" cy="60" r="52"/>
<circle class="dp-bar" id="dpBar" cx="60" cy="60" r="52"/>
</svg>
<div class="dp-center"><strong id="dpVal">0%</strong><span id="dpCap">Complete</span></div>
</div>
<div class="dp-controls">
<button type="button" class="dp-chip" data-v="25">25%</button>
<button type="button" class="dp-chip" data-v="68">68%</button>
<button type="button" class="dp-chip" data-v="92">92%</button>
<input type="range" id="dpRange" min="0" max="100" value="68" aria-label="Progress">
</div>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;justify-content:center;padding:40px 18px}
.dp-card{background:#1e293b;border:1px solid #334155;border-radius:18px;padding:24px;width:100%;max-width:300px;text-align:center}
.dp-ring{position:relative;width:180px;height:180px;margin:0 auto 18px}
.dp-ring svg{width:180px;height:180px;transform:rotate(-90deg)}
.dp-track{fill:none;stroke:#334155;stroke-width:11}
.dp-bar{fill:none;stroke:#22c55e;stroke-width:11;stroke-linecap:round;stroke-dasharray:326.7 326.7;stroke-dashoffset:326.7;transition:stroke-dashoffset .7s cubic-bezier(.22,1,.36,1),stroke .4s}
.dp-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
.dp-center strong{font-size:34px;font-weight:800;font-variant-numeric:tabular-nums;color:#f1f5f9}
.dp-center span{font-size:12px;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em}
.dp-controls{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;align-items:center}
.dp-chip{background:#0f172a;border:1px solid #334155;color:#cbd5e1;border-radius:20px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.dp-chip:hover{border-color:#6366f1;color:#a5b4fc}
.dp-controls input[type=range]{width:100%;accent-color:#6366f1;margin-top:6px}var bar = document.getElementById('dpBar');
var valEl = document.getElementById('dpVal');
var capEl = document.getElementById('dpCap');
var range = document.getElementById('dpRange');
var R = 52, CIRC = 2 * Math.PI * R; // ~326.7
var countTimer = null;
function color(p) { return p < 34 ? '#ef4444' : p < 67 ? '#f59e0b' : '#22c55e'; }
function setRing(p) {
bar.style.strokeDashoffset = String(CIRC - (p / 100) * CIRC);
bar.style.stroke = color(p);
capEl.textContent = p >= 100 ? 'Done' : 'Complete';
}
// Animate the center number from its current value to the target.
function countTo(target) {
cancelAnimationFrame(countTimer);
var start = parseInt(valEl.textContent, 10) || 0;
var t0 = null, dur = 700;
function frame(now) {
if (!t0) t0 = now;
var k = Math.min((now - t0) / dur, 1);
var eased = 1 - Math.pow(1 - k, 3);
valEl.textContent = Math.round(start + (target - start) * eased) + '%';
if (k < 1) countTimer = requestAnimationFrame(frame);
}
countTimer = requestAnimationFrame(frame);
}
function set(p) { p = Math.max(0, Math.min(100, p)); range.value = p; setRing(p); countTo(p); }
document.querySelectorAll('.dp-chip').forEach(function (c) { c.addEventListener('click', function () { set(parseInt(c.getAttribute('data-v'), 10)); }); });
range.addEventListener('input', function () { var p = parseInt(range.value, 10); setRing(p); valEl.textContent = p + '%'; capEl.textContent = p >= 100 ? 'Done' : 'Complete'; });
set(68);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
Donut Progress — Animated Percentage Donut with Counting Label and Thresholds

A donut progress indicator shows a single percentage as a thick ring that sweeps from empty to its value, with the number counting up in the centre and the colour shifting as it climbs — the widget for completion, storage used, goals, and scores. This snippet builds it with an SVG ring and a synced count-up, in plain HTML, CSS, and vanilla JavaScript.
The ring sweep
The progress arc is an SVG circle whose stroke-dasharray equals its circumference (2πr) and whose stroke-dashoffset is moved from the full length (empty) to a fraction of it (the percentage). The SVG is rotated −90° so the sweep starts at the top, stroke-linecap: round softens the leading edge, and a cubic-bezier transition gives the fill a satisfying fast-then-settle motion. Computing the circumference in JS (rather than hard-coding) means changing the radius just works.
A counting centre label
The percentage in the middle doesn't snap — it counts from its previous value to the new one over the same duration as the ring, driven by requestAnimationFrame with an ease-out curve. font-variant-numeric: tabular-nums keeps the digits from shifting sideways as they change. The ring and the number animate together, so the widget feels like one coordinated motion rather than two separate effects.
Colour thresholds
The arc colour reflects the value: red below ~34%, amber in the middle band, and green near completion. This encodes status at a glance — a near-empty quota or a low score reads as a warning without the user parsing the number. The thresholds are a single color(p) function you can retune to your semantics (for storage you might invert them).
Interactive demo, easy integration
Preset chips and a range slider drive the value so you can see the sweep and count respond live; in real use you'd call set(percentage) with your data. The slider path updates the number instantly for scrubbing, while the chips trigger the full animated transition — showing both an immediate and an animated update mode.
Accessible and dependency-free
The centre text always shows the real value, so the meaning survives even if the animation doesn't, and the whole widget is a couple of small functions with no chart library. It's a clean reference for the donut-progress pattern you'll reach for on dashboards, onboarding, and profile-completion cards.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA donut progress ring renders at 68% with controls.
- 2Set a valueCall set(percentage) — the ring sweeps and the number counts.
- 3Try presetsChips jump to preset values with the full animation.
- 4Scrub the sliderDrag to update the value instantly for fine control.
- 5Tune thresholdsEdit color(p) to match your status semantics.
- 6Resize the ringChange the radius; the circumference recomputes automatically.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The arc is an SVG circle with stroke-dasharray set to its circumference (2 × π × r, computed in JavaScript) and stroke-dashoffset moved from the full circumference (empty) to circumference minus the percentage fraction (filled). The SVG is rotated −90° so it starts at the top, and a CSS transition on stroke-dashoffset animates the sweep.
On each change the label animates from its current value to the target over the same duration as the ring, using requestAnimationFrame with an ease-out curve, then snaps to the exact target. Tabular numerals keep the digits from jittering. Driving both the ring and the count over one duration makes them feel like a single coordinated animation.
Edit the color(p) function, which returns a stroke color based on the percentage — by default red below 34, amber to 67, and green above. You can change the breakpoints or invert them; for example, for storage or error rates a high value is bad, so you would flip to green-low, red-high.
Yes. Change the circle radius in the SVG and the stroke-width for thickness; the circumference is derived from the radius in JavaScript, so the fill math stays correct automatically. Adjust the viewBox and container size to match. stroke-linecap: round gives the rounded leading edge.
Hold the value in state and compute the stroke-dashoffset and threshold color as derived values bound to the circle. Animate the center number in an effect with requestAnimationFrame when the value changes, or use a count-up hook. Keep the real value as the text content for accessibility. Tailwind users swap the classes for utilities; the ring math is unchanged.