UI Components Easy
Progress Ring
A circular SVG progress indicator with animated stroke-dashoffset, percentage label, configurable size/stroke, and support for multiple simultaneous rings with distinct colors and labels.
Open in Lab
MCP
svg css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 40px 24px;
}
.page {
max-width: 600px;
margin: 0 auto;
}
.page-title {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 28px;
}
.rings-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
}
@media (max-width: 480px) {
.rings-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.ring-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 10px;
}
.ring-card-label {
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 600;
}
.ring-svg {
display: block;
}
.ring-track {
fill: none;
stroke: var(--surface2);
}
.ring-fill {
fill: none;
stroke-linecap: round;
transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.ring-pct {
font-weight: 800;
fill: var(--text);
font-family: inherit;
}
.ring-lbl {
fill: var(--text-muted);
font-size: 9px;
font-family: inherit;
}
.ring-controls {
display: flex;
justify-content: center;
}
.ctrl-btn {
background: #818cf8;
border: none;
border-radius: 8px;
color: #fff;
font-size: 0.82rem;
font-weight: 600;
padding: 8px 18px;
cursor: pointer;
font-family: inherit;
transition: background .15s;
}
.ctrl-btn:hover {
background: #a5b4fc;
}const SIZE = 120,
STROKE = 10,
R = SIZE / 2 - STROKE;
const C = 2 * Math.PI * R;
function buildRing(wrap) {
const val = +wrap.dataset.value;
const color = wrap.dataset.color;
const label = wrap.dataset.label;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", SIZE);
svg.setAttribute("height", SIZE);
svg.setAttribute("viewBox", `0 0 ${SIZE} ${SIZE}`);
svg.classList.add("ring-svg");
const cx = SIZE / 2,
cy = SIZE / 2;
// Track
const track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
track.setAttribute("cx", cx);
track.setAttribute("cy", cy);
track.setAttribute("r", R);
track.setAttribute("stroke-width", STROKE);
track.classList.add("ring-track");
svg.appendChild(track);
// Fill
const fill = document.createElementNS("http://www.w3.org/2000/svg", "circle");
fill.setAttribute("cx", cx);
fill.setAttribute("cy", cy);
fill.setAttribute("r", R);
fill.setAttribute("stroke-width", STROKE);
fill.setAttribute("stroke", color);
fill.setAttribute("stroke-dasharray", C);
fill.setAttribute("stroke-dashoffset", C);
fill.setAttribute("transform", `rotate(-90 ${cx} ${cy})`);
fill.classList.add("ring-fill");
svg.appendChild(fill);
wrap._fill = fill;
// Pct text
const pct = document.createElementNS("http://www.w3.org/2000/svg", "text");
pct.setAttribute("x", cx);
pct.setAttribute("y", cy + 4);
pct.setAttribute("text-anchor", "middle");
pct.setAttribute("font-size", "20");
pct.classList.add("ring-pct");
svg.appendChild(pct);
wrap._pct = pct;
// Sub label
const lbl = document.createElementNS("http://www.w3.org/2000/svg", "text");
lbl.setAttribute("x", cx);
lbl.setAttribute("y", cy + 18);
lbl.setAttribute("text-anchor", "middle");
lbl.classList.add("ring-lbl");
lbl.textContent = label + "%";
svg.appendChild(lbl);
wrap.appendChild(svg);
animate(wrap, val);
}
function animate(wrap, target) {
wrap._fill.setAttribute("stroke-dashoffset", C);
let start = null;
const duration = 1200;
let counter = 0;
const pct = wrap._pct;
function step(ts) {
if (!start) start = ts;
const progress = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const offset = C - C * (target / 100) * eased;
wrap._fill.setAttribute("stroke-dashoffset", offset);
counter = Math.round(target * eased);
pct.textContent = counter + "%";
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
document.querySelectorAll(".ring-wrap").forEach(buildRing);
document.getElementById("animateBtn")?.addEventListener("click", () => {
document.querySelectorAll(".ring-wrap").forEach((wrap) => {
const newVal = Math.floor(Math.random() * 95) + 5;
animate(wrap, newVal);
});
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Progress Rings</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<h1 class="page-title">Progress Rings</h1>
<div class="rings-grid">
<div class="ring-card">
<div class="ring-wrap" data-value="78" data-color="#818cf8" data-label="CPU"></div>
<p class="ring-card-label">CPU Usage</p>
</div>
<div class="ring-card">
<div class="ring-wrap" data-value="53" data-color="#34d399" data-label="RAM"></div>
<p class="ring-card-label">Memory</p>
</div>
<div class="ring-card">
<div class="ring-wrap" data-value="91" data-color="#f59e0b" data-label="Disk"></div>
<p class="ring-card-label">Disk I/O</p>
</div>
<div class="ring-card">
<div class="ring-wrap" data-value="37" data-color="#f87171" data-label="Net"></div>
<p class="ring-card-label">Network</p>
</div>
</div>
<div class="ring-controls">
<button class="ctrl-btn" id="animateBtn">Re-animate</button>
</div>
</main>
<script src="script.js"></script>
</body>
</html>import { useState, useEffect, useRef } from "react";
const SIZE = 120,
STROKE = 10,
R = SIZE / 2 - STROKE;
const C = 2 * Math.PI * R;
const RINGS = [
{ label: "Conversion", value: 72, color: "#818cf8" },
{ label: "Retention", value: 85, color: "#34d399" },
{ label: "Bounce", value: 38, color: "#f59e0b" },
{ label: "Uptime", value: 99, color: "#f87171" },
];
function ProgressRing({ value, color, label }: { value: number; color: string; label: string }) {
const [current, setCurrent] = useState(0);
const rafRef = useRef<number>(0);
const cx = SIZE / 2,
cy = SIZE / 2;
useEffect(() => {
let start: number | null = null;
const duration = 1200;
function step(ts: number) {
if (!start) start = ts;
const p = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
setCurrent(Math.round(value * eased));
if (p < 1) rafRef.current = requestAnimationFrame(step);
}
rafRef.current = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafRef.current);
}, [value]);
const offset = C - C * (current / 100);
return (
<div className="flex flex-col items-center gap-2">
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#1e2130" strokeWidth={STROKE} />
<circle
cx={cx}
cy={cy}
r={R}
fill="none"
stroke={color}
strokeWidth={STROKE}
strokeDasharray={C}
strokeDashoffset={offset}
transform={`rotate(-90 ${cx} ${cy})`}
strokeLinecap="round"
style={{ transition: "stroke-dashoffset 0.05s linear" }}
/>
<text x={cx} y={cy + 4} textAnchor="middle" fill="#e6edf3" fontSize={20} fontWeight={800}>
{current}%
</text>
<text x={cx} y={cy + 18} textAnchor="middle" fill="#484f58" fontSize={10}>
{label}
</text>
</svg>
</div>
);
}
export default function ProgressRingRC() {
const [values, setValues] = useState(RINGS.map((r) => r.value));
const randomize = () => setValues(RINGS.map(() => Math.floor(Math.random() * 95) + 5));
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex flex-col items-center gap-8">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-8">
{RINGS.map((r, i) => (
<ProgressRing key={r.label} value={values[i]} color={r.color} label={r.label} />
))}
</div>
<button
onClick={randomize}
className="px-4 py-2 bg-[#818cf8]/20 border border-[#818cf8]/40 text-[#818cf8] rounded-lg text-[13px] hover:bg-[#818cf8]/30 transition-colors"
>
Randomize
</button>
</div>
);
}<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const SIZE = 120;
const STROKE = 10;
const R = SIZE / 2 - STROKE;
const C = 2 * Math.PI * R;
const RINGS = [
{ label: "Conversion", value: 72, color: "#818cf8" },
{ label: "Retention", value: 85, color: "#34d399" },
{ label: "Bounce", value: 38, color: "#f59e0b" },
{ label: "Uptime", value: 99, color: "#f87171" },
];
const cx = SIZE / 2;
const cy = SIZE / 2;
const values = ref(RINGS.map((r) => r.value));
const currentValues = ref(RINGS.map(() => 0));
let rafIds = [];
function animateRing(index, targetValue) {
let start = null;
const duration = 1200;
function step(ts) {
if (!start) start = ts;
const p = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
currentValues.value[index] = Math.round(targetValue * eased);
if (p < 1) {
rafIds[index] = requestAnimationFrame(step);
}
}
rafIds[index] = requestAnimationFrame(step);
}
function startAll() {
rafIds.forEach((id) => cancelAnimationFrame(id));
currentValues.value = RINGS.map(() => 0);
values.value.forEach((v, i) => animateRing(i, v));
}
function randomize() {
values.value = RINGS.map(() => Math.floor(Math.random() * 95) + 5);
currentValues.value = RINGS.map(() => 0);
rafIds.forEach((id) => cancelAnimationFrame(id));
values.value.forEach((v, i) => animateRing(i, v));
}
function getOffset(index) {
return C - C * (currentValues.value[index] / 100);
}
onMounted(() => {
startAll();
});
onUnmounted(() => {
rafIds.forEach((id) => cancelAnimationFrame(id));
});
</script>
<template>
<div class="min-h-screen bg-[#0d1117] p-6 flex flex-col items-center gap-8">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-8">
<div v-for="(ring, i) in RINGS" :key="ring.label" class="flex flex-col items-center gap-2">
<svg :width="SIZE" :height="SIZE" :viewBox="`0 0 ${SIZE} ${SIZE}`">
<circle :cx="cx" :cy="cy" :r="R" fill="none" stroke="#1e2130" :stroke-width="STROKE" />
<circle
:cx="cx"
:cy="cy"
:r="R"
fill="none"
:stroke="ring.color"
:stroke-width="STROKE"
:stroke-dasharray="C"
:stroke-dashoffset="getOffset(i)"
:transform="`rotate(-90 ${cx} ${cy})`"
stroke-linecap="round"
style="transition: stroke-dashoffset 0.05s linear"
/>
<text :x="cx" :y="cy + 4" text-anchor="middle" fill="#e6edf3" font-size="20" font-weight="800">
{{ currentValues[i] }}%
</text>
<text :x="cx" :y="cy + 18" text-anchor="middle" fill="#484f58" font-size="10">
{{ ring.label }}
</text>
</svg>
</div>
</div>
<button
@click="randomize"
class="px-4 py-2 bg-[#818cf8]/20 border border-[#818cf8]/40 text-[#818cf8] rounded-lg text-[13px] hover:bg-[#818cf8]/30 transition-colors"
>
Randomize
</button>
</div>
</template>
<style scoped>
</style><script>
import { onMount, onDestroy } from "svelte";
const SIZE = 120;
const STROKE = 10;
const R = SIZE / 2 - STROKE;
const C = 2 * Math.PI * R;
const RINGS = [
{ label: "Conversion", value: 72, color: "#818cf8" },
{ label: "Retention", value: 85, color: "#34d399" },
{ label: "Bounce", value: 38, color: "#f59e0b" },
{ label: "Uptime", value: 99, color: "#f87171" },
];
let values = RINGS.map((r) => r.value);
let currentValues = RINGS.map(() => 0);
let rafIds = [];
function animateRing(index, targetValue) {
let start = null;
const duration = 1200;
function step(ts) {
if (!start) start = ts;
const p = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
currentValues[index] = Math.round(targetValue * eased);
currentValues = currentValues;
if (p < 1) {
rafIds[index] = requestAnimationFrame(step);
}
}
rafIds[index] = requestAnimationFrame(step);
}
function startAll() {
rafIds.forEach((id) => cancelAnimationFrame(id));
currentValues = RINGS.map(() => 0);
values.forEach((v, i) => animateRing(i, v));
}
function randomize() {
values = RINGS.map(() => Math.floor(Math.random() * 95) + 5);
currentValues = RINGS.map(() => 0);
rafIds.forEach((id) => cancelAnimationFrame(id));
values.forEach((v, i) => animateRing(i, v));
}
onMount(() => {
startAll();
});
onDestroy(() => {
rafIds.forEach((id) => cancelAnimationFrame(id));
});
$: cx = SIZE / 2;
$: cy = SIZE / 2;
</script>
<div class="min-h-screen bg-[#0d1117] p-6 flex flex-col items-center gap-8">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-8">
{#each RINGS as ring, i}
{@const offset = C - (C * (currentValues[i] / 100))}
<div class="flex flex-col items-center gap-2">
<svg width={SIZE} height={SIZE} viewBox="0 0 {SIZE} {SIZE}">
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#1e2130" stroke-width={STROKE} />
<circle
cx={cx}
cy={cy}
r={R}
fill="none"
stroke={ring.color}
stroke-width={STROKE}
stroke-dasharray={C}
stroke-dashoffset={offset}
transform="rotate(-90 {cx} {cy})"
stroke-linecap="round"
style="transition: stroke-dashoffset 0.05s linear;"
/>
<text x={cx} y={cy + 4} text-anchor="middle" fill="#e6edf3" font-size="20" font-weight="800">
{currentValues[i]}%
</text>
<text x={cx} y={cy + 18} text-anchor="middle" fill="#484f58" font-size="10">
{ring.label}
</text>
</svg>
</div>
{/each}
</div>
<button
on:click={randomize}
class="px-4 py-2 bg-[#818cf8]/20 border border-[#818cf8]/40 text-[#818cf8] rounded-lg text-[13px] hover:bg-[#818cf8]/30 transition-colors"
>
Randomize
</button>
</div>Features
- Animated stroke — smooth
stroke-dashoffsettransition from 0 to target - Percentage label — animated counter ticks up inside the ring
- Configurable — size, stroke-width, color, and duration via CSS variables
- Multiple rings — stack several rings independently
- Color variants — accent, success, warning, danger presets
How it works
- Ring circumference =
2π × radius stroke-dasharrayis set to the full circumferencestroke-dashoffsetis animated tocircumference × (1 - progress)for the filled arc- A counter animation increments the displayed number in sync