Blog
How to Create an Animated Percentage Loading Circle with HTML, CSS, and JavaScript
An animated percentage loading circle shows a value inside a circular progress ring. In this tutorial, you will build a responsive version with a CSS conic gradient, update it from an accessible number field, and download a complete working example.
Quick answer: Use a CSS custom property to control the ring’s filled percentage, place the number in the center, and update both the CSS value and the progress bar’s ARIA value with JavaScript.
Try the live demoDownload source code
How the percentage progress circle works
The ring uses conic-gradient() to paint a colored arc around a circle. A CSS variable named --progress determines how much of the ring is filled. The number in the center stays readable because it sits above a white inner circle.
JavaScript reads the user’s number, checks that it is a whole value from 0 to 100, and updates the CSS variable. It also updates aria-valuenow, so assistive technology receives the same value displayed visually.
Build the percentage loading circle
The complete example uses plain HTML, CSS, and JavaScript. It does not require jQuery or another library. Add the HTML structure first, then style the ring and connect the form with the JavaScript shown in the code examples below.
1. Create the accessible HTML structure
Use a labeled number input with minimum and maximum values. Give the progress ring role="progressbar", a label, and the appropriate value attributes. The starting value in the markup is 78 percent.
2. Draw and size the ring with CSS
The conic gradient draws the progress arc. The ::before pseudo-element creates the ring’s center. The layout scales down on narrow screens, and visible focus styles help keyboard users operate the form.
3. Update the value with JavaScript
When the form is submitted, JavaScript validates the integer, changes --progress, updates the number, and synchronizes the ARIA value. The browser also announces a short status message after an update.
Customize the progress circle
- Set the initial value: update the input value, CSS custom property, and
aria-valuenowin the HTML. - Change the ring color: replace the blue color in the conic gradient in
styles.css. - Change the ring thickness: adjust the inset value in
.progress::before. - Use a static value: remove the input form and set the progress variable directly in the markup.
Common issues and accessibility notes
Keep the percentage between 0 and 100 so the ring does not exceed its intended range. Use a real label for the input instead of relying on placeholder text. Do not communicate progress with color alone: the centered number and accessible progress attributes provide the value in other ways.
This demo updates only the browser interface. It does not upload, send, or store the value you enter.
Frequently asked questions
Can CSS alone animate a progress circle?
CSS can draw and animate a progress ring, but JavaScript is useful when the value comes from user input or an application state. This example uses a CSS custom property for the visual and JavaScript to update it.
Does this example need a JavaScript library?
No. The example uses standard browser APIs and works without jQuery or a build tool.
How do I use this as a loading indicator?
Update the percentage as your task progresses, and set the aria-valuenow attribute at the same time. For an unknown duration, use an indeterminate spinner instead of showing a misleading percentage.
Download the complete example
The ZIP includes the HTML page, responsive stylesheet, JavaScript, and a README with customization notes. Extract the files and open index.html in a modern browser to run it locally.
Try a percentage loading circle
Enter a whole number from 0 to 100, then update the ring.
<section class="progress-demo"><div class="progress" role="progressbar" aria-label="Loading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="78" style="--progress:78"><span>78%</span></div><form id="progress-form"><label for="percentage">Progress value</label><input id="percentage" type="number" min="0" max="100" step="1" value="78" required><button type="submit">Update circle</button><p id="progress-status" role="status" aria-live="polite"></p></form></section>
.progress-demo { max-width: 420px; margin: 2rem auto; padding: 1.5rem; color: #16233b; font: 1rem/1.5 system-ui, sans-serif; text-align: center; }
.progress { --progress: 78; width: min(58vw, 190px); aspect-ratio: 1; margin: 1rem auto 2rem; display: grid; place-items: center; position: relative; border-radius: 50%; background: conic-gradient(#2866d0 calc(var(--progress) * 1%), #e8edf5 0); transition: background 300ms ease; }
.progress::before { content: ""; position: absolute; inset: 13px; border-radius: inherit; background: #fff; }
.progress span { position: relative; color: #1a2d4e; font-size: 2.5rem; font-weight: 800; }
#progress-form { display: flex; flex-wrap: wrap; gap: .75rem; justify-content: center; }
#progress-form label { flex-basis: 100%; font-weight: 700; }
#percentage, #progress-form button { min-height: 44px; padding: .65rem .9rem; border: 1px solid #cbd6e6; border-radius: 8px; font: inherit; }
#progress-form button { border: 0; background: #2459ad; color: #fff; font-weight: 700; cursor: pointer; }
#progress-status { flex-basis: 100%; color: #1b6b45; }
@media (prefers-reduced-motion: reduce) { .progress { transition: none; } }
const form = document.querySelector("#progress-form");
const input = document.querySelector("#percentage");
const circle = document.querySelector(".progress");
const valueLabel = circle.querySelector("span");
const status = document.querySelector("#progress-status");
form.addEventListener("submit", (event) => {
event.preventDefault();
const value = Number(input.value);
if (!Number.isInteger(value) || value < 0 || value > 100) {
input.setCustomValidity("Enter a whole number from 0 to 100.");
input.reportValidity();
return;
}
input.setCustomValidity("");
circle.style.setProperty("--progress", value);
circle.setAttribute("aria-valuenow", String(value));
valueLabel.textContent = value + "%";
status.textContent = "Progress updated to " + value + " percent.";
});
input.addEventListener("input", () => {
input.setCustomValidity("");
status.textContent = "";
});