How to Animate a Circular Progress Ring
How to build an animated progress ring with SVG, when to use determinate versus indeterminate progress, and why you should not animate fake progress.
Core Takeaway
Use a determinate ring only when you actually know how far along the work is. Build it from an SVG circle, animate stroke-dashoffset, and update the value from real progress events. If progress is unknown, use an indeterminate indicator instead of a ring creeping toward 90 percent. Always pair the ring with a text or accessible value.
Determinate or indeterminate
A determinate ring shows a proportion. An indeterminate spinner shows that work is happening but not how much is left. They answer different questions, and mixing them is what produces the familiar fake progress bar that stalls at 90 percent. If you cannot measure progress, do not draw a ring that pretends to.
The SVG stroke technique
Draw a circle with fill none, give it a stroke, and set stroke-dasharray to the circumference. Then animate stroke-dashoffset from the full circumference down to the offset for the current percentage. Rotate the circle so the ring starts at twelve o'clock. Because this changes stroke properties rather than layout, it animates smoothly.
Ease the updates, not the truth
Progress events often arrive in jumps. Smoothing the visual transition between two real values is fine and looks better. Inventing movement between updates is not. Keep the displayed value tied to the last real measurement, and let it settle there.
Give slow work a visible state
If a step takes a while, a ring that barely moves looks frozen. Pair it with a label that describes the stage, and consider a subtle indeterminate motion inside the ring. Do not spin the whole determinate ring, which destroys the reading of progress.
Label it and expose the value
Put the percentage or step in text next to the ring, and give the element an accessible value with progressbar semantics. A purely visual ring is invisible to screen readers.
Performance
An SVG ring driven by stroke offset is usually cheaper than a canvas redraw or a layout-based bar. Animate it from a single value and avoid re-rendering a large component tree on every frame. Test on a mid-range phone, where loading screens are most common.
Production Implementation Snippet
<svg viewBox="0 0 36 36" role="progressbar"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="65">
<circle cx="18" cy="18" r="15.9" fill="none"
stroke="#e5e5e5" stroke-width="3" />
<circle cx="18" cy="18" r="15.9" fill="none"
stroke="#ff4a1c" stroke-width="3"
stroke-dasharray="100"
stroke-dashoffset="35"
transform="rotate(-90 18 18)" />
</svg>
<!-- pathLength="100" lets you use percentages directly. -->Key Principles to Remember
- Use a determinate ring only when progress is measurable
- Animate stroke-dashoffset on an SVG circle
- Smooth transitions between real values, never invent movement
- Pair the ring with text and progressbar semantics
- Test the loading screen on a mid-range phone
Frequently Asked Questions
Sources
- stroke-dasharray — MDN Web Docs
- ARIA progressbar role — MDN Web Docs
- Animation performance and frame rate — web.dev